Skip to content

Feature/gam graceful order approval#14

Merged
sadrultoaha merged 2000 commits into
developfrom
feature/GAM-Graceful-OrderApproval
Jun 11, 2026
Merged

Feature/gam graceful order approval#14
sadrultoaha merged 2000 commits into
developfrom
feature/GAM-Graceful-OrderApproval

Conversation

@sadrultoaha

Copy link
Copy Markdown
Collaborator

Service accounts without order-approval permission in GAM were causing
media buy creation to fail entirely. The order and line items were
created successfully in GAM but the PERMISSION_DENIED fault on
ApproveOrders was surfaced as a hard failure.

  • Add GAMOrderApprovalPermissionDenied exception to distinguish permanent
    permission failures from transient NO_FORECAST_YET retries
  • Raise the new exception from approve_order when OrderActionError.PERMISSION_DENIED
    is detected, instead of silently returning False
  • Call site 1 (google_ad_manager): catch the exception and start
    status-polling instead of approval-polling
  • Call site 2 (media_buy_create): catch the exception and return success —
    background poller handles eventual activation
  • Add start_order_status_polling / _run_status_watch_thread: polls
    get_order_status() every 30 s for up to 24 h, activates the media buy
    and updates gam_orders.status to APPROVED once a human approves in GAM
  • Also fix stale advertiser_id error: wrap createOrders with a targeted
    catch for CommonError.NOT_FOUND on advertiserId and raise AdCPAdapterError
    with an actionable re-provision message

bokelley and others added 30 commits May 12, 2026 07:53
…responses (prebid#359)

The ``media_buy_state_machine`` storyboard's ``pause_buy``/``resume_buy``/
``cancel_buy`` steps assert ``field_present @ /status`` on the
``update_media_buy`` wire response. Buyers need the resulting status to
confirm the lifecycle transition without an extra ``get_media_buys``
round-trip.

Three response-construction sites were omitting ``status``:

1. The cancel path (``media_buy_update.py``) — set ``status="canceled"``
   on the ``UpdateMediaBuySuccess``.
2. The pause/resume path — set ``status="paused"`` or ``status="active"``
   based on the request's ``paused`` flag.
3. The manual-approval deferred path — surface the buy's CURRENT
   persisted status (the update hasn't transitioned the buy yet —
   it's pending human approval). Read ``current_buy.status`` directly
   rather than via ``_compute_status`` so the path is robust to
   mocked test fixtures whose ``start_time``/``end_time`` aren't
   real datetimes.

Verified with the local storyboard run:

* Before:
  ``state_transitions: passed=false`` —
  ``✗ Response includes updated status: Field not found at path: status``
* After:
  ``state_transitions: passed=true`` (pause + resume + cancel all green)

The ``terminal_enforcement`` scenario still fails — it expects
``INVALID_STATE`` code on attempts to pause/resume/cancel a terminal
buy. That's a separate spec gap (no ``AdCPInvalidStateError`` class
yet) and out of scope for prebid#353.

Three regression tests pin the new behavior:
``test_pause_response_includes_status_paused``,
``test_resume_response_includes_status_active``,
``test_cancel_response_includes_status_canceled``.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nc_accounts (prebid#360)

``SalesagentAccountStore._identity_from_ctx`` was reading tenant_id
exclusively from the ``adcp.server.auth.current_tenant`` ContextVar,
which ``BearerTokenAuthMiddleware`` sets but which doesn't propagate
across the MCP stateful-session task boundary. Every list_accounts /
sync_accounts call from an authenticated buyer landed with
``tenant_id=None`` and surfaced as ``ACCOUNT_NOT_FOUND`` / "no tenant
resolved on the request context."

The same store's ``resolve()`` path already had the fix: use
:meth:`_tenant_from_principal` which falls back to
``auth_info.principal`` → DB lookup. Mirroring that chain inside
``_identity_from_ctx`` makes list/sync task-safe.

Verified locally with ``adcp localmcp list_accounts --json`` (now
returns ``accounts: []`` instead of crashing) and with the full
``pagination_integrity_list_accounts`` storyboard run (all three
scenarios — capability_discovery, setup, pagination_walk — green).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rief (prebid#361)

The ``media_buy_seller/proposal_finalize/get_products_brief`` storyboard
asserts that ``get_products`` calls with ``buying_mode='brief'`` return
a ``proposals[]`` array carrying at least one ``Proposal`` with a
``proposal_id`` buyers can echo into ``create_media_buy(proposal_id=...)``
to execute the bundle. Pre-PR the proposal manager forwarded directly to
``_get_products_impl`` and never emitted ``proposals``.

v1 strategy: split budget evenly across every product the publisher
returned. Each ``ProductAllocation`` references a real ``product_id``
and ``pricing_option_id`` from the response, percentages sum to exactly
100 (compensate for ``100/3`` non-termination on the final allocation
rather than 99.99-rounded), and the proposal gets a fresh
``proposal_id`` per call.

Only ``buying_mode='brief'`` triggers the proposal — wholesale and
refine opt out per spec. Empty product list short-circuits to no
proposal (the spec model requires ``min_length=1`` on allocations).

Future allocation strategies (weighted, refine-loaded drafts) plug into
the same ``_build_v1_brief_proposal`` seam without touching the manager.

## Verified

* Storyboard ``media_buy_seller/proposal_finalize/get_products_brief``:
  PASS — every assertion green including
  ``field_present @ /proposals[0]/proposal_id``.
* 10 new unit tests in ``test_proposal_manager_brief.py`` pin builder
  invariants (sum=100 across 1/2/3-product splits, unique proposal_id
  per call, RootModel unwrapping, optional pricing_option_id).
* Full unit suite: 4295 passed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ebhooks page (prebid#367)

The "Manage Webhooks" button in templates/webhooks.html had two bugs:

1. The link used `{{ script_name }}` but the route that renders the page
   (`operations.py:710`) does not pass `script_name`. In embedded iframe
   context that template variable is undefined, so the link resolved to
   an unprefixed path and 404'd.
2. The destination is the per-tenant principals list, not a webhook
   management page — webhooks are per-principal. The label "Manage
   Webhooks" was misleading.

Use `url_for()` so script-root resolution is automatic, and relabel the
button to "Advertisers" with a users icon to match its actual target.
The user reaches webhook management by clicking into an advertiser.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d#368)

`AdapterConfig` is platform-managed on embedded tenants (only
`gam_sandbox_advertiser_id` is in `PUBLISHER_WRITABLE_FIELDS`), and
the `/settings/adapter` POST is intentionally not opted into
`allow_embedded_writes`. The Targeting Criteria Browser still rendered
the three "Set Include/Exclude/Macro Key" buttons + dropdowns + manual
entry, so clicking them returned 403 and the toast read "Failed to
save include key configuration."

Hide the editor block on embedded tenants and replace it with a
"Managed by platform" notice that points users at the upstream Tenant
Management API. The targeting-key browsing/preview UI below the card
stays visible — operators may want to look up keys when authoring
products.

Null-guard `populateAxeDropdowns` and `updateAxeKeyStatus` against the
now-absent select/status elements so the page JS doesn't throw when
the card body renders the alert variant.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e gate (prebid#372)

Follow-up sweep to prebid#365. The embedded-write gate keys off HTTP verb, so
every POST under `require_tenant_access` without `allow_embedded_writes=True`
returns 403 `embedded_writes_not_permitted` on embedded tenants — even
when the handler is a read-only probe that never touches the DB.

prebid#365 fixed the two AI/Logfire probes called out in Laure's bug report.
Sweep covers the rest of the same class:

- `tenants.test_slack` — sends a test webhook, never writes
- `adapters.test_freewheel_connection` — validates OAuth client_credentials
  against FreeWheel; reads AdapterConfig fallback secret, never writes
- `adapters.test_triton_connection` — validates JWT login against Triton;
  reads AdapterConfig fallback secret, never writes
- `adapters.test_broadstreet_connection` — validates API key against
  Broadstreet network endpoint, never writes
- `settings.test_domain_access` — looks up tenant access for an email
  and flashes the result, never writes

Each handler was inspected to confirm zero DB writes before adding the
opt-in. The model-layer guard in `embedded_tenant_guard.py` remains in
force as defense-in-depth — any accidental Tenant/AdapterConfig write
from these paths would still be caught at commit time.

Longer-term: the verb-based gate misclassifying probes is a design
smell. A `probe=True` decorator argument that the gate honors would be
more durable than per-route opt-in. Filing as a follow-up — out of
scope for this sweep.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tenants (prebid#369)

The "Test Connection" action for AI providers and Logfire on the
Integrations tab failed with "Failed: embedded_writes_not_permitted"
on embedded tenants, because the verb-based embedded-write gate
classifies any POST under `require_tenant_access` as a mutation.

`test_ai_connection` and `test_logfire_connection` are read-only
probes — they validate credentials against the upstream provider and
never write tenant state. Opt them into `allow_embedded_writes=True`;
the model-layer guard in `embedded_tenant_guard.py` remains in force
as defense-in-depth.

Also fix the test-result handlers in `templates/tenant_settings.html`
to render `data.message || data.error` instead of `data.error` alone.
Gate envelopes (and any future role-gate rejections) return both a
stable code in `error` and a human-readable string in `message`; the
old code surfaced the stable code, which read as gibberish to users.

Sweep finding (left as follow-up): the same verb-based-gate trap
exists on `tenants.test_slack`, `adapters.test_freewheel_connection`,
`adapters.test_triton_connection`, `adapters.test_broadstreet_connection`,
and `settings.test_domain_access`. Each is a read-only probe that
could opt in with the same flag.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nts (prebid#370)

On embedded tenants every field in the Policies & Workflows tab
(Brand Manifest Policy, Naming Conventions, Approval Workflows,
Measurement Providers, Product Ranking, Auto-approval thresholds)
silently reverted on save. Two compounding bugs:

1. Route blocked at the boundary. `/settings/business-rules` POST
   used `@require_tenant_access(role=("admin",))` without
   `allow_embedded_writes=True`, so the verb-based gate returned
   403 `embedded_writes_not_permitted` before the handler ran.
2. JS treated the 403 HTML error page as success. `saveBusinessRules`
   in `tenant_settings.js` content-type-branched: any HTML response
   with no `.flash-messages` container fell through to
   `window.location.reload()`. Flask's default 403 error page has no
   flash messages → reload-as-success → user sees their fields revert
   with no error. Affected every 4xx/5xx on that route.

Fix three layers:

- Add `allow_embedded_writes=True` to `update_business_rules`. Per
  Sprint 5 design (`docs/design/embedded-mode-sprint-5.md` §"Pattern:
  shared business logic with the UI"), business rules are
  publisher-managed and edited via the proxied admin UI; the
  management API exposes the same writes.
- Add the per-column business-rules surface to
  `PUBLISHER_WRITABLE_FIELDS[Tenant]` (13 fields covering naming
  templates, approval mode, creative review settings, AI policy,
  advertising policy, brand manifest policy, product ranking prompt,
  human review flag). Platform-identity columns (name, billing_plan,
  is_active, subdomain, external_*) stay locked.
- Add `gam_manual_approval_required` / `mock_manual_approval_required`
  to `PUBLISHER_WRITABLE_FIELDS[AdapterConfig]` — these mirror
  `tenant.human_review_required` onto adapter config and are written
  by the same handler.
- Restructure `saveBusinessRules` to check `response.ok` BEFORE
  content-type branching. Non-2xx responses now surface the error
  (parsing flash messages from HTML when available, falling back to
  the status code) instead of silently reloading.

Added four guard tests in `test_managed_tenant_api.py::TestWriteGuard`:
business-rules columns write, manual-approval adapter columns write,
platform-identity columns stay blocked, and an end-to-end check via the
mock adapter sync field.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d tenants (prebid#371)

* fix(prebid#364): explain empty Allowed Principals dropdown on embedded tenants

On embedded tenants the "Allowed Principals (Advertisers)" multi-select
on Create Product rendered only "No principals configured" — a dead
end. The Buyer Agents section in Settings hides the "Add Buyer Agent"
button on embedded tenants (this is correct: Principal provisioning is
platform-managed via the Tenant Management API), so publishers had no
path to populate the dropdown.

Two compounding things made the UI misleading:

1. The empty-state placeholder didn't distinguish embedded from open
   instances. Publishers saw the same "No principals configured" text
   that suggests they can fix it themselves.
2. Comments in `tenant_settings.html` and `buyer_advertiser_routing.py`
   claimed Principals are "auto-created on first request by the
   embedded-mode auth bypass, which reads X-Identity-Buyer-Principal-Id".
   That mechanism does not exist — grep `src/` for the header returns
   zero matches. Anyone tracing the empty dropdown ran into a dead-end
   comment that confidently pointed at a code path that isn't there.

Fix:

- In `add_product.html` and `add_product_gam.html`, replace the
  disabled `<option>No principals configured</option>` with a
  context-aware empty state. Embedded tenants get an explainer that
  Principals are provisioned by the platform via the Tenant Management
  API; open instances get a pointer to Settings → Buyer Agents.
- Rewrite the misleading comment block in `tenant_settings.html`
  around the advertisers section and the user-visible "auto-created
  from request headers" line — state plainly that embedded Principal
  provisioning goes through the platform API.
- Fix the matching dead-pointer comment in
  `buyer_advertiser_routing.py` near the access-grant logic.

Option B (platform-managed) per `docs/design/embedded-mode-sprint-5.md`
contract. Option A (re-enable UI authoring) would have been a
write-guard expansion that contradicts the existing
`{% if not embedded_view %}` gate on "Add Buyer Agent" — and the model
guard doesn't list Principal at all, so it's the UI gate alone holding
the line. Not the right place to flip the contract.

Terminology cleanup ("Allowed Principals" vs "Buyer Agents" vs
"Advertisers") is deliberately left for a follow-up issue — that's a
larger UX project than a bug fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(prebid#364): update assertions to match corrected embedded-mode copy

The original test asserted on the misleading "auto-created from request
headers" copy that prebid#364 removed (because the auto-create mechanism does
not exist — see prebid#364 PR description). Update the assertions to match
the new, accurate copy that explains platform-API provisioning.

Also refresh the class docstring to drop the same misleading claim about
header-based auto-creation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nse boundary (prebid#375)

The manual-approval path on ``update_media_buy`` read ``MediaBuy.status``
straight from the DB column and surfaced it on the
``UpdateMediaBuySuccess`` response. The persisted column accepts a
broader set than the AdCP wire enum — ``draft`` (model default) and
``pending_approval`` (manual-approval create path) are both valid in
storage but not in ``MediaBuyStatus``. fastmcp's request-/response-side
Pydantic validation rejected the response with
``INVALID_REQUEST[status]: Input should be 'pending_creatives',
'pending_start', 'active', 'paused', 'completed', 'rejected' or
'canceled'``, which surfaced as an E2E failure on
``test_complete_campaign_lifecycle_with_webhooks`` (prebid#374) and on every
PR's CI run after the manual-approval status-emission was added in prebid#353.

Fix:

- Add ``_to_wire_status`` in ``media_buy_list.py``. Takes any input
  (``str | MediaBuyStatus | None``) and returns either a wire-valid
  string from the seven-member enum, or ``None`` for values the wire
  rejects. Case-insensitive on string input.
- Apply it at the manual-approval response site in
  ``update_media_buy.py``. ``current_status`` is now guaranteed
  wire-valid (or ``None``) before reaching ``UpdateMediaBuySuccess``.

The other three response-status sites (cancel, pause/resume, final
``_compute_status`` path) already emit values from the wire enum
by construction.

Tests:

- ``TestToWireStatus`` (6 cases): wire-valid passthrough, case
  insensitivity, persisted-only rejection (``draft``,
  ``pending_approval``), ``None``/empty/non-string handling.
- ``test_manual_approval_response_coerces_non_wire_db_status_to_none``:
  end-to-end behavior — a persisted ``pending_approval`` does not leak
  to the response.
- ``test_manual_approval_response_preserves_wire_valid_db_status``:
  wire-valid statuses still pass through unchanged.

Verified locally:

- Failing E2E ``test_complete_campaign_lifecycle_with_webhooks`` passes
  against the full Docker stack.
- ``tox -e unit`` (4314 tests) and ``tox -e integration`` (1030
  update_media_buy-adjacent tests) both green.

Fixes prebid#374.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prebid#376)

The MCP Python SDK's ``StreamableHTTPSessionManager`` stores
``_server_instances`` as a process-local dict. Multi-replica deployments
without sticky LB routing on ``Mcp-Session-Id`` see ``tools/list`` and
``tools/call`` randomly 404 with "Session not found" when a request
lands on a replica that didn't handle ``initialize``.

A 10-attempt probe against the Wonderstruck deployment confirmed the
dice roll: ``initialize`` always 200 (creates session on whichever
replica answers); ``tools/list`` and ``tools/call`` with the same
session ID succeeded only when they happened to land on the same
replica (~50/50 each). Yesterday's compliance baseline (170 steps, 12
tools discovered) caught the deployment during a single-replica window;
today the same baseline rerun returned 0 tools because
``discoverAgentProfile`` calls ``initialize`` → ``tools/list`` in tight
succession, and ``tools/list`` lost the affinity coin flip half the
time.

``serve()`` has supported ``stateless_http: bool`` since adcp 5.0
(``adcp/server/serve.py:2053`` sets ``mcp.settings.stateless_http``
from the kwarg unconditionally, so ``FASTMCP_STATELESS_HTTP`` env
alone has no effect — the kwarg overrides FastMCP's reader). This
plumbs the kwarg through ``_serve_kwargs`` gated on
``ADCP_STATELESS_HTTP``:

* Unset / falsy → stateful (default). Single-replica prod, local dev,
  in-process tests, and the compliance-runner storyboard sweep keep
  the session-reuse perf optimization.
* ``ADCP_STATELESS_HTTP=true`` → stateless. Each request creates a
  fresh transport context; multi-replica works without sticky LB.

Per the FastMCP deployment doc
(https://gofastmcp.com/v2/deployment/http): stateless mode is the
recommended pattern for horizontal scaling — cookie-based stickiness
is unreliable because most MCP clients use ``fetch()`` and drop
``Set-Cookie``. Header-based stickiness on ``Mcp-Session-Id`` would
also work (the AdCP SDK forwards the header cleanly) and would keep
session-reuse perf on prod compliance runs; this env var doesn't
preclude that — the deployment chooses by setting / unsetting
``ADCP_STATELESS_HTTP``.

Tests verify the env var maps to the kwarg correctly across true /
false / unset and case variants. Existing
``test_serve_kwargs_middleware_order.py`` extended with the new
``stateless_http``-focused cases.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up:
- SellerA2AClient for in-process A2A handler testing (prebid#694)
- PgBuyerAgentRegistry.with_caching() factory (prebid#692)
- v3 storyboard CI gate that actually asserts (prebid#693)
- Sequence[T] widening on response-only list fields (prebid#635)
- Composed lifespan preservation when public_url is callable (prebid#680)
- ads.txt MANAGERDOMAIN fallback discovery (prebid#704/prebid#705)
- validate_adagents_structure helper (prebid#708)
- webhook_signing.supported boot validator (prebid#695)

Audited the codebase for workarounds the bump should now obsolete. One
real candidate: AgentCardPublicUrlMiddleware (190 LOC) — prebid#680 means
transport="both" + callable public_url now works. Replacing it with a
public_url=resolver callable will land separately.

Two workarounds the bump can't eliminate, filed upstream:
- serve(lifespan=) hook missing — adcp-client-python#709
- cross-class entity overrides still need type:ignore[assignment] —
  adcp-client-python#710

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lution (prebid#380)

Wonderstruck-class publishers ship bare ``authorized_agents`` entries
(``{url, authorized_for}`` only, no ``authorization_type``) alongside a
top-level ``properties[]`` block. The AdCP SDK's strict resolver returns
``[]`` for these, so:

  - Publisher Partnerships chip rendered "Pending 0/0" — misleading
    operators into thinking the publisher hadn't authorized us yet.
  - Products UI used to bind anyway via a homegrown heuristic, then a
    prior pass tightened it to match the SDK — regressing Wonderstruck.

This change introduces a four-state ``PublisherPartnerStatusKind``
(``authorized`` | ``unbound`` | ``pending`` | ``no_properties`` |
``unreachable``) and an explicit permissive resolution path:

  - ``aao_lookup_service.get_publisher_partner_status`` uses the SDK
    strictly first; falls back to ``unbound`` only when our entry is
    bare and the file has top-level properties. Surfaces a conformance
    hint so operators can nudge the publisher to add a typed binding.
  - ``property_discovery_service._extract_properties`` mirrors the same
    classification and, on the unbound branch, gates top-level
    properties to those carrying a ``type=domain`` identifier matching
    the publisher_domain — closes the attack vector where a publisher
    could bare-list us + claim arbitrary app/podcast/DOOH bundle IDs.
  - Shared shape helpers in ``src/services/_adagents_shapes.py``
    (``is_bare_entry``, ``find_agent_entry``, ``top_level_properties``)
    cover the full schema selector set including ``signal_ids`` /
    ``signal_tags``.
  - New nullable ``aao_status_kind`` column on ``publisher_partners`` —
    legacy NULL rows fall back to the existing derivation in
    ``_partner_to_dict`` so the rollout is safe under rolling deploys.
  - JS chip styles for ``unbound`` ("Authorized (non-conformant file)")
    and ``no_properties`` ("No properties listed").

Upstream issues filed in parallel for ecosystem alignment:
  - adcontextprotocol/adcp#4478 — typed
    ``authorization_type: "all_top_level_properties"`` variant so
    publishers have a spec-conformant shape; once shipped we can
    deprecate the local permissive shim.
  - adcontextprotocol/adcp-client-python#711 — permissive resolver API.
  - adcontextprotocol/adcp-client#1721 — TS SDK per-agent resolution +
    permissive mode for cross-SDK consistency.

Fixes prebid#377

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…NVALID_STATE, WWW-Authenticate (prebid#383)

* fix(compliance): residual fixes from 7.1.0 probe — INVALID_REQUEST, INVALID_STATE, WWW-Authenticate

Closes three residual storyboard failures observed in the 7.1.0 comply()
re-probe against Wonderstruck after prebid#348/prebid#349 fixes deployed:

1. **error_compliance/nonexistent_product** — pre-dispatch validation in
   ``_create_media_buy_impl`` raised ``ValueError`` (past start_time, reversed
   dates, etc.) and the outer ``except (ValueError, PermissionError)`` handler
   emitted ``Error(code="VALIDATION_ERROR")``. ``VALIDATION_ERROR`` is not in
   the AdCP 3.0 ``STANDARD_ERROR_CODES`` enum, so buyer agents walking the
   enum for self-correction silently drop the error. Change wire code to
   spec-canonical ``INVALID_REQUEST``. Storyboard expects
   ``PRODUCT_NOT_FOUND``, ``PRODUCT_UNAVAILABLE``, or ``INVALID_REQUEST``;
   sibling ``reversed_dates_error`` accepts ``VALIDATION_ERROR`` or
   ``INVALID_REQUEST``. ``INVALID_REQUEST`` is the only value in both
   sets and is the spec-canonical choice.

2. **media_buy_state_machine/pause_canceled_buy** — ``_update_media_buy_impl``
   had a terminal-state guard on cancel (re-cancel raises
   ``AdCPNotCancellableError``) but the pause/resume branch dispatched
   straight to the adapter. Spec requires rejection with
   ``/adcp_error/code == "INVALID_STATE"`` for pause-of-canceled.
   New exception ``AdCPInvalidStateError`` (``error_code="INVALID_STATE"``,
   recovery ``correctable``, 422) covers the symmetric guard. Fires
   BEFORE adapter dispatch on both terminal states (``canceled``,
   ``completed``) for both actions (``paused=True``, ``paused=False``).
   Idempotency-spec friendly: same payload yields the same wire code on
   retry regardless of which adapter would have handled the transition.

3. **security_baseline/probe_unauth** — RFC 6750 §3 requires a
   ``WWW-Authenticate: Bearer`` header on every 401 from a Bearer-protected
   resource. Upstream ``adcp.server.auth.BearerTokenAuthMiddleware`` on the
   MCP leg returns 401 without the header for missing/invalid tokens; the
   A2A leg and ``SigningVerifyMiddleware`` already emit it correctly.
   New ``WWWAuthenticateMiddleware`` (in ``core/middleware/``) wraps the
   ASGI ``send`` callable and injects the bare ``Bearer`` challenge on
   401 responses missing the header. Case-insensitive presence check so
   stacking is safe; no-op on 2xx / 3xx / 4xx-other / 5xx so a 403 doesn't
   confuse buyers about which auth scheme to apply. Registered AFTER
   ``AdminWSGIMount`` so Google-OAuth-gated admin paths short-circuit
   before the buyer-protocol challenge sees them.

Bundled together because they ship in a single redeploy cycle and the
PR-title-check enforces one Conventional Commit prefix per PR; the three
fixes are independent at the code level (different files, different
behavioural surfaces, different tests).

## Residuals still open (not in this PR)

- ``pagination_integrity_list_accounts/first_page`` — ``has_more`` returns
  false on a 3-seeded list with ``max_results=2``. Pagination logic in
  ``_apply_pagination`` is correct in isolation; the storyboard's seed→list
  chain isn't reaching the impl with the expected request shape. Needs
  separate investigation with end-to-end repro.
- ``media_buy_seller/proposal_finalize/get_products_refine`` — refine path
  on ``get_products`` returns no ``proposals[]``. ``SalesAgentProposalManager.refine_products``
  raises ``UNSUPPORTED_FEATURE``. Substantial feature work, separate PR.
- ``security_baseline/assert_mechanism`` — likely fixed transitively by
  the ``WWW-Authenticate`` header; re-probe after deploy will confirm.

## Verification

- ``make quality`` — 4292 passed, 14 skipped, 19 xfailed
- New targeted tests: 30/30 (15 middleware × scope cases, 6 INVALID_STATE
  behavioural × class cases, 9 INVALID_REQUEST schema cases)
- Existing ``test_max_daily_spend_exceeded`` updated to expect the new
  wire code per the change description
- Structural guards (transport-agnostic-impl, no-toolerror-in-impl, etc.)
  pass; the new middleware is a salesagent-side ASGI wrapper, not in
  ``_impl`` scope

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(comply): mark WWWAuthenticateMiddleware as workaround for adcp-client-python#712

The upstream defect is in ``adcp/server/auth.py:411`` —
``BearerTokenAuthMiddleware._unauthenticated`` emits a ``JSONResponse`` with
``status_code=401`` but no ``WWW-Authenticate`` header. The sibling
``A2ABearerAuthMiddleware._send_unauthenticated`` in the same file (line
1024) gets it right. Filed at adcontextprotocol/adcp-client-python#712.

Documents the deletion plan: when the upstream fix ships and we bump
``adcp``, the middleware's case-insensitive presence check makes it a
no-op, so the order is safe — bump → re-probe → remove the middleware
and its registration in a follow-up PR.

No code change. Comments only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review(comply): code-reviewer nits from PR prebid#383

Fixes two factually-wrong claims and adds a regression guard, all
flagged by the code-reviewer pass:

1. ``media_buy_create.py:2418`` comment claimed "``VALIDATION_ERROR`` is
   not in the spec enum and gets dropped by buyer agents walking
   ``STANDARD_ERROR_CODES``." It IS in the enum
   (``adcp/types/generated_poc/enums/error_code.py:46``). Replace with
   the actual justification — the storyboard-intersection argument —
   and add a forward note about the dead ``PermissionError`` catch path
   (no code inside this try raises it today; if a future principal-
   ownership check moves in, split the except so PermissionError maps
   to ``PERMISSION_DENIED``).

2. ``test_invalid_request_envelope_on_validation_failure.py`` carried
   the same wrong claim in its module docstring. Rewrite to reflect
   the actual intersection argument.

3. Add ``test_www_authenticate_runs_after_admin_mount_and_before_signing``
   to ``test_serve_kwargs_middleware_order.py`` — pins ``WWWAuthenticateMiddleware``
   between ``AdminWSGIMount`` (so admin Google-OAuth 401s don't get a
   misleading Bearer challenge) and ``SigningVerifyMiddleware`` (so
   signing-emitted 401s flow through the injector). A future refactor
   that moves the middleware either direction surfaces here instead of
   silently breaking RFC 6750 §3 compliance.

No behaviour change. Quality: 4293 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ne=True (prebid#385)

* feat(proposal): implement v1 refine_products + flip capabilities.refine=True

Closes the ``media_buy_seller/proposal_finalize/get_products_refine``
storyboard failure observed in the 7.1.0 comply() probe against
Wonderstruck.

## What changed

* ``SalesAgentProposalManager.refine_products`` now has a real
  implementation instead of raising ``UNSUPPORTED_FEATURE``. Delegates
  to ``_get_products_impl`` for products, decorates the response with
  a fresh ``Proposal`` via the existing
  ``_build_v1_brief_proposal`` even-split allocator, and populates
  ``refinement_applied[]`` from the buyer's ``refine[]`` asks.
* ``ProposalCapabilities.refine`` flipped from ``False`` to ``True``.
  The framework router now dispatches ``buying_mode='refine'`` requests
  to ``refine_products`` instead of falling through to ``get_products``
  (which never populated ``refinement_applied``).
* New ``_build_v1_refinement_applied`` helper: dispatches each refine
  entry's ``scope`` (``request`` / ``product`` / ``proposal``) to the
  matching ``RefinementApplied{1,2,3}`` variant. Status is uniformly
  ``applied`` with a v1-acknowledgement note explaining the response
  carries a fresh-but-unchanged-strategy proposal. Forward-compat:
  unknown scopes and malformed entries (e.g. product-scope without
  ``product_id``) are silently dropped rather than crashing the
  response.

## v1 vs v2 semantics

v1 is explicitly acknowledgement-shaped. Storyboard validation is
``field_present @ /proposals`` and ``response_schema`` — both satisfied
without semantic refinement. The note in each ``refinement_applied``
entry signals the v1 limitation so buyers see honest behaviour: the
proposal is fresh but the allocation hasn't been re-strategized from
the ask content. v2 will swap the even-split for an allocation that
actually honors asks (drop product / shift budget / shape targeting)
once ``ProposalStore`` is wired to load the prior draft by
``proposal_id``. The wire contract stays stable across v1/v2.

## Tests

Mirrors the pattern in ``test_proposal_manager_brief.py``:

* ``TestSalesAgentProposalManagerCapabilities`` pins
  ``capabilities.refine=True`` and the unchanged sales_specialism.
* ``TestBuildV1RefinementApplied`` covers every scope variant,
  multi-entry ordering preservation, malformed-entry drop, unknown-
  scope drop, and RootModel-wrapped entry unwrap.
* ``TestRefinementAppliedNote`` pins the buyer-facing breadcrumb so a
  future content swap is intentional.

11 new tests; quality green (4273 passed, 14 skipped, 19 xfailed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review(refine): cap buyer-supplied echo + drop dead fallback (PR prebid#385 nits)

Addresses three review items:

**security-reviewer L1 (Should-Fix): bound buyer-supplied refine echo.**
``RefinementApplied2.product_id`` and ``RefinementApplied3.proposal_id``
are typed ``str`` with no length cap in the adcp library, so an
adversarial buyer could ship 10MB ids and force us to hold them through
Pydantic validation and echo them back. Added two caps in
``core/proposal/manager.py``:
* ``_MAX_REFINE_ID_LEN = 256`` — per-id length cap; oversize ids are
  DROPPED (not truncated — truncation corrupts id semantics for
  downstream correlation). Real AdCP ids look like ``prop_abc123`` /
  ``prod_video_outdoor``; 256 chars leaves generous headroom.
* ``_MAX_REFINE_ENTRIES = 50`` — array length cap, slice up front so
  an N-million-entry array can't drive allocation pressure even
  before the per-entry loop runs.
* New ``_is_safe_id`` helper centralizes the per-id check (also catches
  non-str values, defense-in-depth for callers bypassing Pydantic).

**code-reviewer nit 1: drop dead ``or getattr(req, "refine", None)``.**
``_coerce_to_request_model`` returns a ``GetProductsRequest`` Pydantic
model that always has the ``refine`` attribute (default ``None``), so
the fallback can never fire. Simplified to
``getattr(req_model, "refine", None) or []``.

**code-reviewer nit 2: drop over-promised telemetry comment.**
The dropped-scope comment claimed "missing telemetry" without actually
emitting any. Replaced with the honest framing — forward-compat for
spec additions, known v1 limitation tracked for v2 telemetry — and
matched the docstring's "silently dropped" claim.

## New tests (6)

``TestRefineEchoLengthCaps`` in ``test_proposal_manager_refine.py``:
* ``test_oversized_product_id_dropped`` — 257-char id (cap+1) dropped
* ``test_oversized_proposal_id_dropped`` — same cap on proposal scope
* ``test_empty_product_id_dropped`` — zero-length symmetry
* ``test_max_length_id_accepted`` — boundary at exactly 256 chars
* ``test_excess_array_length_truncated`` — 100-entry array → 50 echoed
* ``test_non_string_product_id_dropped`` — defense-in-depth for non-str

Quality green: 4279 passed, 14 skipped, 19 xfailed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r): DetachedInstanceError (prebid#392)

* fix(prebid#336): enable Add Publisher on embedded view

Embedded tenants couldn't add publisher partners — the UI hid the
controls and the API 403'd direct POSTs. Without publishers there are
no AuthorizedProperty rows, which empties the property selector and
blocks Create Product on embedded tenants.

PublisherPartner is not in the model-layer guard's locked set
(embedded_tenant_guard locks only Tenant core columns, AdapterConfig,
and signing creds), so publisher-partner mutations are publisher-managed
by definition. Apply the same opt-in pattern as PR prebid#340: pass
allow_embedded_writes=True on the four mutation routes (add / delete /
sync / refresh) and drop the redundant _reject_if_embedded helper.

Template: unhide the +Add Publisher / Refresh-all buttons and the modal;
update the "Platform-managed" banner to scope only to the agent URL
(which IS platform-managed) rather than the partner roster.

Tests: flip TestPublisherPartnershipsReadonlyOnEmbedded →
TestPublisherPartnershipsEditableOnEmbedded; add positive coverage
test_managed_tenant_can_add_publisher_partner under
TestEmbeddedViewAllowsPublisherManagedWrites. Move the api-mode JSON
envelope assertion and gate-polarity check to the OIDC enable route
(still platform-managed, api_mode=True).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scheduler): DetachedInstanceError on multi-buy delivery batch

Production trace (2026-05-08): the daily delivery-report batch
succeeded for the first media buy with a reporting_webhook, then
raised DetachedInstanceError on media_buy.tenant for every subsequent
buy in the same batch.

Root cause: each iteration calls _get_media_buy_delivery_impl, which
opens its own ``with get_db_session()``. Because get_db_session uses
a scoped_session, the inner ``scoped.remove()`` closes the SAME
session the outer batch loop is using, detaching every MediaBuy row
loaded by MediaBuyRepository.get_all_by_statuses. The first iteration
happens to complete before the inner remove() fires; iteration 2+
hits a detached instance on the next relationship access.

Fix: eager-load MediaBuy.tenant via joinedload in the scheduler's
fetch. The tenant value is materialized into the instance state and
survives detach, so media_buy.tenant returns the cached Tenant
without lazy-loading through a closed session.

Added eager_load_tenant=True parameter on
MediaBuyRepository.get_all_by_statuses (default False so the
media_buy_status_scheduler caller — which doesn't access tenant —
doesn't pay the JOIN cost).

Regression test reproduces the production trace exactly: two media
buys with reporting_webhook configured; without the fix, only one
webhook is sent and the second iteration raises DetachedInstanceError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(prebid#335): regression tests for product-save validation paths

The user reported "Internal Server Error" when saving a product
without selecting a Property, expecting a validation flash instead.
PR prebid#340 closed prebid#335 by fixing the embedded-write 403 that the
storefront proxy was misreporting; these tests document and lock in
the post-fix contract so the bug can't return undetected.

Six new scenarios under tests/admin/test_product_creation_integration.py:

- test_add_product_without_property_returns_validation_error_not_500:
  POST with name + pricing, no property selection. Asserts 200 + the
  "Please select at least one property tag" flash text + no leaked
  Product row.

- test_add_product_malformed_inputs_never_return_500 (parametrized):
  only_name, name_and_pricing_only, invalid_pricing_rate,
  invalid_property_mode, property_ids_mode_no_selection. Every case
  must surface a validation response, never a raw 500.

Both tests share a new ``authenticated_admin`` fixture that uses
UserFactory (CLAUDE.md Pattern #8) — re-loads the tenant inside the
factory's session to avoid DetachedInstanceError from the
test_tenant fixture's closed session.

All 17 product-create + delivery-webhook integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: tighten scheduler regression + bound display_name + pin guard layer

Addresses code-review and security-review feedback on the three preceding
commits before merge.

Scheduler test (code-review C2): tie the regression assertion to the
actual failure mode via caplog. Without this, the test depended on
``await_count`` as a second-order signal; the new check catches
``DetachedInstanceError`` directly so a future refactor that changes
the send count for unrelated reasons can't silently mask the bug.

Guard layer consistency (code-review I4): new test
``test_publisher_partner_not_locked_at_model_layer`` exercises a
PublisherPartner write on an embedded tenant without the
``management_api_caller`` bypass. If a future change adds the model to
``embedded_tenant_guard``'s locked set, this test fails with a pointer
to remove ``allow_embedded_writes=True`` from the four
publisher_partners routes. Companion note added in
embedded_tenant_guard.py near the existing locked-table listeners.

Display-name length cap (security nit 1): add a 255-char gate on
``display_name`` in ``add_publisher_partner`` so a hostile or buggy
embedded caller can't persist multi-MB strings that later render into
admin UI / API responses.

Filed prebid#391 for the systemic scoped_session/nested-get_db_session()
trap surfaced during scheduler triage (code-review C1). The scheduler
fix in ac3a3a7 is the right immediate patch; the underlying trap
needs its own redesign and is tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e mounts work (prebid#393)

* fix(prebid#357): use url_for() for tenant admin links so embedded-mode mounts work

The Setup Checklist (and other admin surfaces) emitted bare /tenant/<id>/...
hrefs. Under the Storefront's /storefront/salesagent mount, those resolved
against the storefront host instead of the proxied salesagent path and
returned 404 from the parent app.

Service layer (setup_checklist_service.py, dashboard_service.py,
business_activity_service.py) now builds URLs via flask.url_for() so the
emitted hrefs include SCRIPT_NAME automatically. Templates that previously
hand-prepended {{ request.script_root }} are migrated to url_for() in the
same pass for consistency with CLAUDE.md Pattern #6.

SetupChecklistService runs from two transports: Flask (admin UI) and
Starlette via adcp.server.serve() (MCP/A2A). validate_setup_complete()
fires inside _create_media_buy_impl on the non-Flask path, where url_for()
would raise RuntimeError. _build_url() catches that and returns None;
validate_setup_complete only reads task['name'], so the gate behavior is
unchanged. Added tests/unit/test_setup_checklist_no_flask_context.py to
pin this contract.

JS string interpolations (`${tenantId}/...`) are intentionally untouched
— url_for can't help with runtime IDs, and CLAUDE.md Pattern #6 already
endorses `scriptRoot + path` for that case.

One pre-existing FIXME left: tenant_settings.html /settings/raw form
posts to a route that has no handler; touched only with a clarifying
comment, not a behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(prebid#357): also tolerate BuildError when url_for runs in a foreign Flask app

The tenant_management_api blueprint runs as its own Flask app
(tests/integration/test_managed_tenant_api.py:54 — a bare Flask()
with only that blueprint registered). When SetupChecklistService is
invoked from that app (via tenant_status_service → /status), url_for()
for admin-UI endpoints raises werkzeug.routing.BuildError, not
RuntimeError, because the endpoint isn't registered there.

_build_url now catches both. The management API never reads
action_url (it surfaces configure_path from a static map in
tenant_status_service._CONFIGURE_PATHS), so None is correct.

Adds TestServiceWorksInForeignFlaskApp to pin the contract — Flask
context exists, but the endpoint can't be built.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rebid#394)

* chore(deps): bump adcp 5.3.0 → 5.4.0; drop three local workarounds

5.4.0 ships every upstream issue I filed yesterday plus a bonus.

## Drops with the bump

| Workaround | Upstream fix |
|---|---|
| ``core/middleware/www_authenticate.py`` (74 LOC) — injected ``WWW-Authenticate: Bearer`` on 401 because the MCP-leg ``BearerTokenAuthMiddleware._unauthenticated`` returned ``JSONResponse(status_code=401)`` without the header | adcp-client-python#712 → prebid#715: ``_unauthenticated`` now emits the header on both the MCP dispatch path AND the ASGI ``_send_unauthenticated`` path, matching what the A2A leg already did |
| ``core/idempotency._ReplayMarkingStore`` (~120 LOC + private-symbol coupling to ``_WRAPPED_FUNCTIONS`` / ``_clone_response`` / ``_resolve_call_args`` / ``_to_dict`` / ``CachedResponse``) — reimplemented the full ``IdempotencyStore.wrap`` body inline to inject ``replayed: true`` on cache hits per AdCP L1/security rule 4 | adcp-client-python#714 → prebid#717: ``IdempotencyStore.wrap`` now does ``response["replayed"] = True`` on the cache-hit branch natively |
| ``mcp_header_name="x-adcp-auth"`` + ``mcp_bearer_prefix_required=False`` — the MCP leg accepted ONLY the custom legacy header, EXCLUDING the spec-canonical ``Authorization: Bearer``. Caused ``security_baseline/probe_api_key`` storyboard failures | adcp-client-python#720 → prebid#721: ``Authorization: Bearer`` is always accepted; ``mcp_legacy_header_aliases=[...]`` is a purely additive opt-in for adopters with deployed legacy clients |

Net diff: -533 LOC including the obsolete test files.

## Auth shape after bump

Old (broken for spec-compliant clients):
```python
BearerTokenAuth(
    validate_token=_validate_token,
    mcp_header_name="x-adcp-auth",
    mcp_bearer_prefix_required=False,
)
```

New (spec compliance + zero break for legacy clients):
```python
BearerTokenAuth(
    validate_token=_validate_token,
    mcp_legacy_header_aliases=["x-adcp-auth"],
)
```

``Authorization: Bearer <token>`` is now accepted on both legs by default
(the spec carrier per RFC 6750). The ``x-adcp-auth`` legacy header keeps
working unchanged for any early-adopter MCP client still on it. Migration
is a one-way drift with no flag day.

## Files removed

- ``core/middleware/www_authenticate.py``
- ``core/tests/test_idempotency_replay_marking.py``
- ``tests/unit/test_www_authenticate_middleware.py``
- The ``WWWAuthenticateMiddleware``-ordering test in
  ``tests/unit/test_serve_kwargs_middleware_order.py``

## Verification

- ``make quality``: 4294 passed, 14 skipped, 19 xfailed
- Existing ``_ReplayMarkingStore`` callers in ``get_idempotency_store()``
  swapped to plain ``IdempotencyStore`` — same constructor signature,
  upstream provides the injection
- ``test_serve_kwargs_middleware_order.py`` updated to drop the
  ``WWWAuthenticateMiddleware``-position pin (middleware no longer
  exists)
- After deploy, the compliance probe's ``security_baseline/probe_api_key``
  and ``assert_mechanism`` storyboard steps should flip to pass — closes
  the auth-header gap we filed as bokelley#386 (which can now
  be closed as "fixed upstream")

## Closes (when deployed)

- bokelley#386 — multi-header auth (now native via prebid#720)
- The remaining compliance-probe residual on ``security_baseline``
  (3 → 1 failure; only ``proposal_finalize/create_media_buy`` left,
  tracked separately as prebid#387)

## Doesn't pick up

- 5.4.0's ``LazyPlatformRouter.proposal_stores=`` / ``proposal_store_factory=``
  (prebid#722/prebid#724) — this is the wiring point for bokelley#387.
  Separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review(deps): switch test fixtures to new BearerTokenAuth shape (PR prebid#394 nit)

Code-reviewer flagged that two test files still constructed
``BearerTokenAuth`` with the legacy ``mcp_header_name`` /
``mcp_bearer_prefix_required`` kwargs even though production swapped
to ``mcp_legacy_header_aliases=[...]`` in this PR's main commit. The
tests passed against 5.4.0 (back-compat shim works) but emitted
``DeprecationWarning`` and stopped mirroring the production config —
production now ACCEPTS ``Authorization: Bearer`` on the MCP leg
alongside ``x-adcp-auth``, but these test fixtures still wired the
old exclusive-header semantics.

## Changes

* ``tests/unit/test_per_leg_bearer_auth.py``: ``_production_auth`` and
  ``_build_mcp_app`` updated to the new shape. The inner
  ``BearerTokenAuthMiddleware`` construction now passes
  ``legacy_header_aliases=auth.resolved_mcp_legacy_aliases()`` and
  ``legacy_aliases_bearer_prefix_required=auth.legacy_aliases_bearer_prefix_required``
  in place of the deprecated ``header_name`` / ``bearer_prefix_required``
  pair. Mirrors what adcp.server.serve._wrap_mcp_with_auth does
  natively against 5.4.0.

* ``tests/unit/test_agent_card_auth_scheme.py``: ``_production_auth``
  same swap; module-level docstring updated to reflect that both legs
  now default to ``Authorization`` and the MCP leg additively accepts
  ``x-adcp-auth`` for legacy adopters (not as an exclusive override).

## Verification

* 10/10 targeted tests pass
* ``make quality`` — 4294 passed, 14 skipped, 19 xfailed; warning
  count dropped from 117 to 105 (the deprecation warnings are gone)

No behavior change beyond what PR prebid#394's main commit ships. Tests now
exercise the same wire shape production uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ebid#395)

Production log volume on Fly was 70%+ noise. Three categories of offender,
all in our code (not the adcp SDK):

src/core/context_manager.py
- Delete leftover ``console.print`` debug blocks: 🔍 PRE-COMMIT WEBHOOK
  DEBUG (16 lines per workflow step update), 🔍 POST-COMMIT WEBHOOK DEBUG
  (5 lines), and the 🚀 WEBHOOK / ⚠️ WEBHOOK SKIPPED chatter. These read
  as leftover instrumentation from a past debugging session and fire on
  every workflow step status change.
- Convert remaining ``console.print`` calls to ``logger.debug`` (lifecycle
  events: context/step creation, object linking, webhook dispatch) or
  ``logger.warning`` / ``logger.exception`` (errors).
- Drop the unused ``rich.console.Console`` import and module-level
  ``console = Console()`` singleton.

src/core/helpers/adapter_helpers.py
- Demote ``[ADAPTER_SELECT]`` / ``[ADAPTER_CONFIG]`` from ``logger.info``
  to ``logger.debug`` (10 call sites). These are tracing-grade fields,
  not operational signals — they should be off in production unless
  someone is actively debugging adapter selection.

src/core/audit_logger.py
- Collapse the per-detail audit fan-out: instead of one ``logger.info``
  per ``details`` dict key (N+1 lines per audit event), emit a single
  ``"<message> | <details>"`` line. Full details still persist to
  ``AuditLog.details`` for structured queries — the per-line fan-out was
  legible in local tail but flooded production stdout.

The ``adcp.audit`` logger name is shared with the SDK's ``LoggingAuditSink``
but the noisy emissions come from our own ``audit_logger`` in
``src/core/audit_logger.py``, not the SDK — so nothing to file upstream.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ix trafficker_id log bug (prebid#397)

Three followups from the audit pass on production fly logs.

src/core/logging_config.py
- Add ``UvicornAccessNoiseFilter`` and attach it to ``uvicorn.access`` in
  both production (JSON) and development (standard) modes. The filter
  drops 2xx GET/POST/HEAD/OPTIONS access lines on /mcp[/] and /health —
  the two endpoints hit constantly by storefront MCP pollers and Fly's
  TCP+HTTP health checks. 4xx/5xx still surface so auth failures and
  server errors aren't buried. Other paths (admin UI, /a2a, /.well-known,
  /mcp-debug, etc.) are unaffected. Behavioral contract pinned by 18
  parametrized tests in tests/unit/test_uvicorn_access_filter.py.

src/adapters/gam/managers/targeting.py
- Rate-limit the "Could not load geo mappings file" + "Using empty geo
  mappings" warnings to once per process lifetime via a module-level flag.
  Each ``GAMTargetingManager`` instance fires on every adapter selection,
  so the same warning flooded the log on every GAM-tenant request. The
  underlying file-not-found is still tracked in prebid#396 (it means GAM geo
  targeting silently produces empty results in prod and needs a
  packaging-side fix).

src/adapters/google_ad_manager.py
- Fix the "Could not auto-detect trafficker_id: User instance has no
  attribute 'get'" warning. The googleads SOAP client returns a zeep
  complex object — it supports __getitem__ and attribute access but NOT
  ``.get()``. The old code called ``current_user.get('name', 'Unknown')``
  inside the success-log f-string, which raised AttributeError AFTER
  ``self.trafficker_id`` was already assigned. The ID was being detected
  correctly all along; only the success log was failing and producing a
  misleading warning on every request. Switched to ``getattr`` for the
  optional ``name`` field.

Filed prebid#396 to track the underlying production OOM kill on the iad
machine and the missing ``gam_geo_mappings.json`` packaging issue — both
infrastructure-level and out of scope here.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uy(proposal_id=…) (prebid#390)

* feat(proposal): wire Postgres-backed ProposalStore for create_media_buy(proposal_id=…)

Closes the proposal-lookup gap that made
``proposal_finalize/create_media_buy`` fail with
``INVALID_REQUEST: Invalid budget: 0.0``. Without a wired
:class:`ProposalStore`, the framework's ``proposal_dispatch`` had no
backing for the buyer's ``proposal_id`` and ``create_media_buy``
landed in package-derivation with zero packages.

Pieces:

- ``proposals`` table (migration ``r0s1t2u3v4w5``) — mirrors the
  v1.5 ``ProposalRecord`` dataclass with multi-tenant scoping and a
  partial unique on ``(account_id, media_buy_id) WHERE media_buy_id
  IS NOT NULL`` for reverse-index lookups
- :class:`SalesAgentProposalStore` — implements every
  :class:`adcp.decisioning.proposal_store.ProposalStore` Protocol
  method (put_draft / get / commit / try_reserve_consumption /
  finalize_consumption / release_consumption / mark_consumed /
  discard / get_by_media_buy_id) against the new table. Atomic CAS
  via ``SELECT … FOR UPDATE`` serializes parallel callers.
  Cross-tenant probes collapse to ``None`` / ``PROPOSAL_NOT_FOUND``
  per the Protocol's principal-enumeration defense.
- :class:`_LazyPlatformRouterWithStore` — thin subclass that adds
  the ``proposal_store_for_tenant`` accessor the framework's
  ``proposal_dispatch`` duck-types. Upstream
  :class:`LazyPlatformRouter` doesn't expose it (only the eager
  :class:`PlatformRouter` does, via ``proposal_stores=``).
- Wired into ``build_router()`` — single shared store across
  tenants; isolation runs inside the store on
  ``expected_account_id``.

v1 lifecycle compromise (documented in the store docstring): the
storyboard flow goes brief → create_media_buy WITHOUT an
intermediate finalize step, but the framework's
:meth:`try_reserve_consumption` requires the proposal to be in
``committed`` state. The store auto-commits at ``put_draft`` time
with a 7-day ``expires_at`` so the buyer flow unblocks today. The
Protocol surface is unchanged — only the internal lifecycle state
differs. When the manager declares ``finalize=True`` in v2, swap
to canonical ``draft`` + explicit commit.

Tests:

- ``tests/integration/test_proposal_store.py`` — 15 integration
  tests against real Postgres covering put_draft auto-commit,
  payload round-trip, refine overwrite, cross-tenant probe defense
  (get + try_reserve), two-phase consumption lifecycle, atomic CAS
  double-reservation rejection, reverse-index lookup with
  ``expected_account_id`` enforcement, idempotent release/discard
- ``tests/unit/test_lazy_router_with_proposal_store.py`` — 3 unit
  tests pinning the router subclass's accessor wiring
- ``tests/unit/test_proposal_store_attributes.py`` — 2 unit tests
  pinning ``is_durable=True`` (production-mode gate) and the
  7-day default hold window

Refs prebid#387

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(proposal): adopt adcp 5.4 — drop workarounds, use upstream surface

Upstream shipped both items we filed during prebid#387:

- adcp-client-python#722 → 5.4: LazyPlatformRouter accepts
  ``proposal_stores=`` and ``proposal_store_factory=``. Deletes our
  ``_LazyPlatformRouterWithStore`` subclass.
- adcp-client-python#723 → 5.4: ``ProposalCapabilities.auto_commit_on_put_draft``
  shipped option B from the issue. The framework now calls
  ``store.commit`` immediately after ``put_draft`` for opted-in
  managers. Deletes our store-side ``state=COMMITTED`` workaround
  in ``put_draft``.

Migration:

- Bump ``adcp>=5.4.0``.
- ``SalesAgentProposalManager.capabilities`` declares
  ``auto_commit_on_put_draft=True``; framework owns the
  DRAFT → COMMITTED promotion via
  ``auto_commit_ttl_seconds=604800`` (7-day default, matches our
  prior store-side hold window).
- ``core/main.build_router`` calls ``LazyPlatformRouter(...)`` directly
  with ``proposal_store_factory=lambda _tid: shared_store``. Factory
  shape over eager dict because the store is a single shared
  instance — eager dict would force boot-time tenant enumeration
  and miss tenants registered after boot.
- ``SalesAgentProposalStore.put_draft`` writes spec-canonical
  ``draft`` state with ``expires_at=None``. The ``_committed_hold``
  constructor param and the 7-day default are gone — the framework's
  ``auto_commit_ttl_seconds`` capability owns the TTL.

Tests:

- Integration: 16 tests rewritten — put_draft asserts DRAFT (not
  COMMITTED), reservation lifecycle tests use a ``_put_and_commit``
  helper that mirrors the framework's auto-commit dispatch, new
  ``TestCommit`` class covers commit promotion + idempotency +
  payload-drift rejection, new test pins that put_draft on a
  COMMITTED record raises ``INTERNAL_ERROR`` per Protocol.
- Unit: deleted ``test_lazy_router_with_proposal_store.py`` (no
  subclass to test); trimmed ``test_proposal_store_attributes.py``
  to the durability flag only (the 7-day default belongs to the
  framework now).

Refs prebid#387

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(proposal): address review — split compound account_id, account-scoped locks, fail-closed unscoped methods

Review feedback on PR prebid#390:

**B1 (blocker): _resolve_tenant_id_for_account returned account_id
verbatim.** SalesagentAccountStore.resolve mints ``f"{tenant_id}:{ref}"``
(``ref`` defaults to ``"default"``; storyboard runs use ``"acct_demo"``).
The framework passes ``ctx.account.id`` straight into ``put_draft``,
so every prod ``put_draft`` would FK-violate on ``proposals.tenant_id``.
Fixed: split on ``":"`` and take the prefix. New integration test
``test_put_draft_handles_compound_account_id`` regresses this — uses
the real shape the framework emits.

**Security MAJOR (×3): try_reserve / finalize / release did
SELECT FOR UPDATE then filtered account_id in Python.** Cross-tenant
probes acquired the row lock, leaking existence via timing AND
providing a DoS primitive against legitimate same-tenant operations.
Fixed: ``account_id`` moved into the WHERE clause so cross-tenant
probes never acquire the lock. Two new integration tests pin the
behavior:
- test_finalize_cross_tenant_collapses_to_internal_error
- test_release_cross_tenant_is_noop (verifies foreign tenant's
  release doesn't roll back the owner's CONSUMING reservation)

**Security MAJOR (×2): discard() and mark_consumed() Protocol
signatures lack ``expected_account_id``.** Any caller obtaining a
``proposal_id`` could destroy / terminate another tenant's proposal.
Neither is called by adcp 5.4's ``proposal_dispatch`` today; fixed:
both raise ``NotImplementedError`` with an ERROR log. Future framework
versions that begin calling them surface loudly before reaching prod.
Two new tests pin the fail-closed behavior.

**MAJOR M3: _serialize_recipes silently passed dicts through.**
Violates "No quiet failures" (CLAUDE.md). Fixed: raises TypeError on
non-Pydantic input — caller has to pass typed Recipe instances.

**MINOR m3: lazy imports inside every method.** Hoisted
``ProposalRecord``, ``ProposalState``, ``AdcpError`` to module level —
no circular import; the salesagent already imports the library
at module-load time elsewhere.

**NIT n2/n3: stale temporal references.** Dropped "v1 auto-commit
workaround landed before prebid#723 and is gone" from the store docstring
and "v1 auto-commits at put_draft time" from the Proposal model
docstring. Per CLAUDE.md: don't document the prior behavior.

**M2 partial coverage: end-to-end account_id shape test added.**
``test_put_draft_handles_compound_account_id`` exercises the realistic
``"tenant_id:default"`` shape the framework actually emits. Full
end-to-end (HTTP → proposal_dispatch → store) deferred to compliance
probe post-deploy — the unit layer pins every store-side invariant.

24 integration + unit tests pass; ``make quality`` clean (4311 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review(proposal): expires_at guard + 8 lock-in tests cherry-picked from PR prebid#398

Two additions from @bokelley's parallel prebid#398 work that prebid#390 lacked:

**1. Defense-in-depth expires_at check inside try_reserve_consumption.**
Security reviewer L1 finding on prebid#398: a buyer holding a COMMITTED
proposal past its ``expires_at`` could reserve and finalize
indefinitely. The framework's
``proposal_dispatch._hydrate_proposal_context`` checks expiry on the
get-side, but ``try_reserve_consumption`` is reachable from dispatch
paths that bypass that filter (and from adopter callers that go
straight to the store). New three-line guard inside the existing row
lock raises ``PROPOSAL_EXPIRED`` with ``recovery="correctable"``.
Mirrors upstream :class:`InMemoryProposalStore._evict_expired_locked`
but surfaces the event rather than silently deleting so audit trails
survive.

**2. mark_consumed restored as implemented Protocol method.** Earlier
fail-closed pattern was over-cautious for a Protocol method the
framework doesn't currently call. Now matches the upstream
:class:`InMemoryProposalStore.mark_consumed` shape verbatim, with a
WARNING audit log on every call so unexpected invocations are
visible. Documented Protocol-signature gap (no
``expected_account_id``) — same upstream constraint that
:meth:`discard` has; ``discard`` stays fail-closed because the user's
follow-up list didn't include it.

**Tests (9 added, 1 replaced):**
- test_reserve_past_expires_at_raises_expired (locks in #1)
- test_release_silent_no_op_on_missing
- test_release_silent_no_op_on_cross_account
- test_finalize_idempotent_on_consumed_matching_media_buy
- test_finalize_mismatched_media_buy_raises
- test_mark_consumed_promotes_to_consumed
- test_mark_consumed_idempotent_on_matching
- test_mark_consumed_mismatched_raises
- test_mark_consumed_unknown_raises_internal_error
- Replaced ``test_mark_consumed_raises_not_implemented`` with the
  four ``TestMarkConsumed`` cases above

All cherry-picked from prebid#398's test suite (locked-in shapes already
correct in prebid#390's code per @bokelley's close comment). 32 integration
+ unit tests pass; ``make quality`` clean (4311 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…des (prebid#400)

adcp 5.4.0 prebid#718 ships ``SchemaVariant[T]`` + a mypy plugin that
rewrites the annotation to ``Any`` for override-compat purposes,
retiring the ``# type: ignore[assignment]`` stamps adopters used
to carry on cross-class entity overrides.

The 12 sites in src/core/schemas/ all match the cross-class pattern
the marker targets:

- 4× geo_*_exclude — parent declares Geo{Country,Region,Metro,
  PostalArea}ExcludeItem; we substitute the inclusion variant
- 2× creatives — parent declares CreativeAsset; we substitute
  our extended Creative
- 1× deployments — parent declares Deployments; we substitute
  SignalDeployment
- 1× media_buys — parent declares MediaBuy; we substitute
  the GetMediaBuysMediaBuy delivery-context view
- 1× ext — parent declares ExtensionObject; we use dict
- 1× sync_creatives.creatives — parent's CreativeAsset; we
  use our local CreativeAsset subclass
- 1× query_summary — parent's QuerySummary; we use our local
- 1× media_buy_deliveries / 1× creatives in delivery.py —
  delivery-context views

mypy.ini gets ``adcp.types.mypy_plugin`` added to the plugins
line alongside the existing sqlalchemy + pydantic plugins.

Tradeoff (documented upstream): inside the override, mypy sees the
field as ``Any``. ``typing.cast(list[T], self.field)`` recovers
precise inference at call sites that need it. None of the touched
sites currently rely on inside-override inference at usage sites,
so no cast() is needed for this change.

make quality: 4319 passed, 14 skipped, 19 xfailed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_shutdown=) (prebid#401)

adcp 5.4.0 prebid#713 ships native lifespan hooks on ``serve(transport='both')``,
which is exactly what the middleware was hand-rolling. The middleware
intercepted ASGI ``lifespan.startup`` / ``lifespan.shutdown`` scope events
to fire scheduler start/stop coroutines because earlier SDK versions
didn't expose a user-supplied lifespan extension point.

Now they do. The SDK's ``on_startup`` / ``on_shutdown`` kwargs take the
same ``Callable[[], Awaitable[None]]`` shape that ``_start_schedulers``
and ``_stop_schedulers`` already had, so the swap is mechanical:

- Drop ``SchedulerLifespanMiddleware`` from the ``asgi_middleware`` list.
- Pass ``on_startup=[_start_schedulers]`` / ``on_shutdown=[_stop_schedulers]``
  in ``_serve_kwargs()``, conditional on ``include_scheduler`` (tests still
  skip).
- Delete ``core/middleware/scheduler_lifespan.py`` (61 LOC).
- Update the ``_serve_kwargs`` docstring to reference the SDK hook instead.

The middleware ran scheduler shutdown with a 10s ``asyncio.wait_for``
guard; the SDK fires hooks unguarded. Our ``_stop_schedulers`` already
caps its own awaitables (delivery + media-buy status schedulers each
join their internal task groups with a bounded timeout), so dropping
the outer wait_for is fine — it was defensive double-bookkeeping.

make quality: 4319 passed, 14 skipped, 19 xfailed.

Closes the second of three local rip-outs unlocked by the adcp 5.4.0
bump. The third (AgentCardPublicUrlMiddleware → public_url callable)
lands separately.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rebid#402)

The salesagent middleware existed because earlier SDK versions either
hardcoded ``http://localhost:{port}/`` into the agent card with no
override hook (pre-5.0) or crashed ``transport='both'`` startup when
``public_url`` was a callable (5.2.0, ``AttributeError: 'function'
object has no attribute 'router'``).

adcp 5.3.0 prebid#680 fixed the composed-lifespan crash. 5.4.0 has confirmed
the callable path works under ``transport='both'`` in production. The
SDK's ``serve(public_url=PublicUrlResolver)`` is now the right primitive
for per-request agent-card URL derivation.

## What lands

- ``core/main._resolve_public_url(request) -> str`` — pure function
  with the same header-precedence rules the middleware enforced:
  PUBLIC_URL env > X-Forwarded-Host > Host, X-Forwarded-Proto for
  scheme, ``http://`` for loopback / ``https://`` otherwise.
- Wired as ``"public_url": _resolve_public_url`` in ``_serve_kwargs``.
- Drop the middleware from the ``asgi_middleware`` list.
- Delete ``core/middleware/agent_card_public_url.py`` (189 LOC).
- Replace ``test_agent_card_public_url_middleware.py`` with 13 tests
  of the new resolver covering: X-Forwarded-Host precedence, Host
  fallback, comma-chain stripping, proto override, https default,
  loopback http exception (matches SDK's ``_validate_card_url``),
  PUBLIC_URL env override, no-headers fallback.
- Update ``test_serve_kwargs_middleware_order`` — replace the
  middleware-present assertion with a ``public_url is callable``
  assertion.

## Net diff

-442 LOC (mostly the middleware + ASGI plumbing tests it required)
+195 LOC (resolver doc + resolver tests + updated order test)
= -247 LOC net.

## What stays the same in production behavior

- PUBLIC_URL env takes precedence (single-host deploys unchanged).
- X-Forwarded-Host derives multi-tenant subdomain URLs (same as before).
- X-Forwarded-Proto controls scheme.
- Loopback hosts get ``http://`` (the SDK's _validate_card_url enforces
  this — non-loopback ``http`` returns 500 from the SDK).

## What's different (intentional)

- The middleware refused to rewrite non-loopback URLs (defensive
  pass-through). The resolver always derives the URL afresh. This is
  safer: with the static-public_url fallback gone, the resolver is the
  single source of truth and there's no "what gets rewritten vs
  passed through" branching to reason about.
- Response-body buffering and content-length recalculation are gone —
  the SDK builds the card from the resolver's URL directly.

make quality: 4322 passed, 14 skipped, 19 xfailed.

Closes the third of three local rip-outs unlocked by the adcp 5.4.0
bump (after SchemaVariant migration and SchedulerLifespanMiddleware
removal).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rebid#403)

Reflection-based export/import for tenant-scoped data. Walks SQLAlchemy
metadata to discover all 41 tenant-scoped tables (including transitive
chains like media_packages → media_buys, strategy_states → strategies,
object_workflow_mapping → workflow_steps), then exports/imports rows in
FK-dependency order inside a single transaction.

Built for moving clients from legacy hosting to embedded mode (flip
is_embedded=True) on the same Postgres deployment. Also supports
cross-deployment moves via target-tenant-id retargeting and a
strip-secrets mode that wipes Fernet ciphertext + plaintext bearer
credentials (admin_token, slack/audit/hitl webhook URLs, GAM refresh
token, push_notification_configs auth, webhook subscription secret
hash, creative/signals agent auth_credentials, ai_config api_key).
principals.access_token is intentionally preserved so buyers'
MCP/A2A integrations keep working post-import.

Safety:
- alembic_revision pinned in the bundle; import refuses on schema mismatch
- pre-flight collision check on subdomain, virtual_host, principals.access_token
  raises TenantImportCollisionError with precise message instead of opaque
  IntegrityError
- strict column filtering when alembic revisions match (drops are a bug,
  not noise); --allow-schema-drift downgrades to warning
- Core-level inserts bypass the embedded_tenant_guard ORM listeners; the
  operator-CLI trust boundary is the equivalent privilege level
- export bundle written 0600 (contains tenant secrets)
- import writes an audit_logs row capturing operator, mode, flip-to-embedded,
  target_tenant_id, row counts
- explicit rollback on any import-path failure

CLIs:
  scripts/ops/export_tenant.py acme --out acme.json [--strip-secrets]
  scripts/ops/import_tenant.py acme.json --mode=replace --flip-to-embedded
  scripts/ops/import_tenant.py acme.json --target-tenant-id new --allow-schema-drift
  scripts/ops/import_tenant.py acme.json --dry-run

19 integration tests covering discovery, round-trip, collision modes,
embedded flip, retargeting, strip-secrets (encrypted + plaintext bearer),
strict filtering, schema mismatch, audit log emission.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…01 from A2A (prebid#407)

Crawlers and probes hitting `GET /robots.txt` on the API host fell through
to the inner A2A app, where BearerTokenAuth 401'd them. Production logs
filled with `"GET /robots.txt HTTP/1.1" 401 Unauthorized` (plus a
paired `adcp.server.auth` JSON line per rejection), and well-behaved
crawlers got an inconsistent signal — 401 is not a stable "do not
crawl" answer.

robots.txt is a host-level resource, not a per-tenant one, so neither
Flask nor A2A is the right owner. `AdminWSGIMount` already short-
circuits an analogous static response (the apex `/` → `/signup` 302),
so colocate the robots short-circuit there:

- `GET`/`HEAD /robots.txt` → 200 `text/plain` with
  `User-agent: *\nDisallow: /\n` and `cache-control: public, max-age=86400`
- non-safe methods (POST, etc.) fall through unchanged — the short
  circuit only covers actual crawler probes

Tests: four scenarios in `TestAdminWSGIMountRobotsTxt` covering the
GET body+headers, HEAD-returns-no-body contract, POST falling through,
and the bug itself (the request must not reach the inner A2A app on
a non-admin host).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prebid#408)

Surfaces session/connection auth-flag state directly in the
EmbeddedTenantWriteError message so SyncJob.error_message (and the
status widget that renders it) shows exactly why the guard fired
without log-diving. Distinguishes the three failure modes:

- session_present=False         → object was detached at flush time
- session_flags={all None/False} → flag never set on this session
- session_flags={one True}      → guard misread the flag (should be impossible)

No behavior change beyond the longer error text.
…rebid#410)

Anonymous internet traffic hammers /mcp constantly (bot probes,
misconfigured clients), and every rejection emits two log lines:
- one uvicorn access line ("POST /mcp HTTP/1.1" 401 Unauthorized)
- one structured ``adcp.server.auth`` line ("a2a auth rejected" …)

PR prebid#397's filter deliberately kept 4xx/5xx so auth failures weren't
buried, but the structured log already captures the signal — the
access line is dupe noise. In production logs this is by far the
dominant source of /mcp-related log volume.

Per-surface status-code policy now:

* /mcp[/]  — drop 2xx AND 401. Other 4xx (403/404/422) and all 5xx
  still log; those indicate a real problem worth investigating.
* /health  — drop 2xx only. A 4xx/5xx on the health surface always
  means a config or platform bug worth seeing.

Implementation splits the single regex into two named patterns so the
status-code carve-out per surface stays readable. Test reshuffle:

* test_drops_noise — new combined parametrize: /mcp 2xx + /mcp 401 +
  /health 2xx (one row per cause).
* test_keeps_real_signal — /mcp non-401 4xx (403, 404, 422), /mcp 5xx,
  /health non-2xx (401, 503), and /.well-known/oauth-protected-resource
  401 (the OAuth dance start, which is signal not noise).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…argeting, formats (prebid#381)

* chore(freewheel): capture & anonymize publisher API fixtures

Adds 56 anonymized FreeWheel Publisher API response fixtures covering
/services/v3/ (XML, commercial: advertisers, campaigns, insertion_orders,
placements, agencies) and /services/v4/ (JSON, inventory: sites,
site_sections, site_groups, series, videos, video_groups,
inventory_packages).

Includes the capture and anonymization scripts under scripts/dev/freewheel/
so fixtures can be regenerated when the test bearer token rotates
(7-day TTL). Both scripts read identifying constants from env vars rather
than embedding them, keeping the source tree free of publisher-specific
identifiers. .env.template documents the two new optional vars.

Anonymization scrubs PII (sales person, trafficker, content credits) and
publisher-identifying values (network_id, advertiser_id, content/title
fields, external Salesforce IDs) while preserving referential integrity
via deterministic memoized replacements. Structural fields (statuses,
stages, currencies, budget shapes, schedules, link hrefs) are preserved
verbatim so fixtures remain useful as wire-format ground truth for the
upcoming adapter client.

No production code wired yet — these fixtures are the foundation for the
FreeWheel adapter client rewrite (next change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): rewrite adapter for real /services/v3+v4 API surface

Replaces the skeletal OAuth-client-credentials client (wrong path shape,
wrong content type, wrong auth model) with a bearer-token client that
matches the actual FreeWheel Publisher API surface verified end-to-end
against a publisher's test network:

  - /services/v4/* (JSON, inventory taxonomy: sites, sections, series,
    videos, video groups, inventory packages) — read-only
  - /services/v3/* (XML, commercial entities: advertisers, campaigns,
    insertion orders, placements, agencies) — full reads + verified
    create_campaign/delete_campaign writes

Module layout under src/adapters/freewheel/:
  _transport.py    — bearer auth, accept negotiation, status mapping
  _inventory.py    — v4 JSON inventory client
  _commercial.py   — v3 XML commercial client
  _pagination.py   — shared page-walking iterator (DRY)
  entities.py      — Pydantic models for both surfaces
  client.py        — FreeWheelClient facade composing the above

Connection config is now a single api_token field (7-day TTL, no refresh
flow — rotate when expiry approaches). Migration of existing tenants
will require manual reconfiguration once any are provisioned.

Tests:
  - 37 new unit tests across transport / inventory / commercial replaying
    captured fixtures from tests/fixtures/data/freewheel/
  - Updated config schema + adapter + roundtrip integration tests

Adapter-level wiring (create_media_buy/update_media_buy/check_status
using the new client) is intentionally deferred to a follow-up PR; live
mode still returns pending_credentials until that integration lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): add insertion-order, placement, and campaign-update writes

Completes the v3 write surface so adapters and external callers can
construct a full campaign hierarchy. All endpoints verified end-to-end
against the publisher's test network (probe entities created with
clearly tagged names, then deleted):

  - POST /services/v3/insertion_order — min body: name + campaign_id.
    Server defaults: stage=NOT_BOOKED, currency=EUR. Auto-attaches an
    assigned_user from the bearer token's identity (silently dropped by
    our model's extra="ignore" config).
  - POST /services/v3/placement — min body: name + insertion_order_id.
    Server defaults: status=IN_ACTIVE, placement_type=NORMAL.
  - PUT /services/v3/campaign/{id} — partial update. PATCH returns 405,
    so v3 uses PUT semantics for "only fields in the body are modified".
  - DELETE /services/v3/insertion_order/{id} and
    DELETE /services/v3/placement/{id} — hard deletes, same shape as
    campaign delete.

Adds put_xml() to the transport (POST handler already existed) and a
matching test for the PUT method. 6 new commercial client tests covering
create+delete for IO and placement, and the partial-update semantics for
update_campaign (verifies only passed fields appear in the request body).

create_media_buy wiring still uses the pending_credentials stub — that
work belongs in the adapter-mapping PR where we decide how AdCP Package
maps onto FreeWheel's Campaign→IO→Placement hierarchy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): wire create_media_buy and check_status to live v3 API

Replaces the pending_credentials stub in create_media_buy with the real
v3 write flow per Mapping A:

  AdCP MediaBuy → FW Insertion Order (the commercial transaction)
  AdCP Package  → FW Placement (one per package, child of the IO)
  FW Campaign   → per-buy wrapper above the IO

In live mode the adapter now:
  1. Creates a FW Campaign named after the AdCP buy (po_number-derived or
     timestamp), parented to the principal's freewheel advertiser_id.
  2. Creates a FW Insertion Order under that campaign.
  3. Creates one FW Placement per AdCP Package under the IO.
  4. Returns ``media_buy_id = "freewheel_{io.id}"`` — the IO is the unit
     of commerce, so it's what subsequent calls reference.

check_media_buy_status now fetches the IO (not the Campaign) and reports
its ``stage`` (NOT_BOOKED, BOOKED, etc.), which is where IO booking state
lives in v3. Falls back to ``status`` for safety.

FreeWheelError from any of the three create calls is translated to a
CreateMediaBuyError with code ``upstream_error``. Partial-failure orphans
(e.g. Campaign created then IO fails) are not cleaned up in v1 — they
sit as IN_ACTIVE entities and don't deliver. A best-effort rollback is
a future refinement.

Deferred (each its own follow-up, flagged in the adapter docstring):
  - update_media_buy live wiring (needs update_io/update_placement probes)
  - add_creative_assets (v3 /creative endpoint returned 404 in probes —
    the creative surface is somewhere we haven't mapped)
  - get_media_buy_delivery (reporting lives on a different API surface)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): add update_insertion_order/update_placement and document scope blockers

Adds the v3 partial-update verbs to the commercial client, both verified
end-to-end against the publisher's test network (probe entities created
and deleted):

  - update_insertion_order(id, **fields) — PUT /services/v3/insertion_order/{id}.
    Supports nested-dict fields like ``budget={"budget_model": ...,
    "impression": ...}`` for impression-target adjustments.
  - update_placement(id, **fields) — PUT /services/v3/placement/{id}. The
    delivery-level pause/resume mechanism (status=IN_ACTIVE / ACTIVE).

Extends _build_xml to handle one level of nested dicts so partial body
updates with nested elements (budget, schedule) serialise correctly.

Adapter-level update_media_buy wiring intentionally NOT included — two
publisher-scope blockers surfaced during probes that need resolution
before the adapter can wire cleanly:

  1. Per-package operations need AdCP package_id -> FW placement_id
     lookup. v3 /placements doesn't honour ?insertion_order_id filter
     (returns full network list); no nested-collection endpoint at v3;
     v4 has the nested form but our token gets a 403 IAM deny.
  2. Per-package budget changes don't fit FW's data model — budget
     lives on the IO, not on the placement. Would need a different
     mapping (one-IO-per-package) or per-package tracking we don't have.

Creative endpoints discovered at v4 (creatives, creative_assets, assets,
ad_assets, asset_versions, creative_versions all 403 IAM-deny). v3 has
no creative paths (404). add_creative_assets wiring blocked on publisher
granting creative scopes. Documented in the adapter docstring so the
next conversation with the publisher has the asks ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(freewheel): clarify the creative endpoint split (publisher vs demand)

After a docs deep-dive prompted by the user pointing at the FreeWheel
Marketplace Creatives reference, we now have a clearer picture of the
two-API creative model and the AdCP semantic mismatch it implies.

Publisher-side (the API our bearer is for):
  PUT /services/v4/mkpl_creatives/{id}
  body: approval_status (Approved|Rejected|Pending) + approval_notes

This is a *moderation* workflow, not a creation workflow. The buyer
registers the creative through their own DSP; it shows up in the
publisher's marketplace queue; the publisher (us, via Talpa's token)
approves or rejects it. AdCP's sync_creatives (buyer registering
creatives) therefore has no direct publisher-side equivalent — the
adapter's approval surface maps to AdCP's creative review/approval
flow, not its creation flow.

Three sibling type-specific endpoints exist alongside the unified one
(mkpl_exchange_programmatic_creatives, mkpl_private_direct_sold_creatives,
mkpl_private_programmatic_creatives). All 403 IAM-deny on our token —
the ask to Mathijs becomes specific: grant scope on
``/services/v4/mkpl_creatives``.

Buyer-side (POST /demand/v1/accounts/{seat_id}/ads) is the FreeWheel
Demand/Beeswax product. Out of scope for publisher-token-driven
integration — Talpa as a publisher wouldn't have a Demand seat to
delegate.

No code change beyond the adapter docstring — just capturing the
finding so the next round of asks to the publisher is precise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): add v4 creative_resources client (full CRUD scope verified)

After two earlier docs links pointed at the wrong API surface (Demand v1
and Marketplace approval), the user surfaced
https://api-docs.freewheel.tv/publisher/reference/creative-management-api-v4
which gave us the correct path: /services/v4/creative_resources. Probing
shows our existing publisher bearer is fully entitled there — we just
hadn't tried the right name.

Verified end-to-end (2026-05-12):
  - GET  /services/v4/creative_resources           (list, 70 creatives)
  - GET  /services/v4/creative_resources/{id}      (single, ``{creative: {...}}`` envelope)
  - POST /services/v4/creative_resources           (auth+validation reaches us)
  - PUT  /services/v4/creative_resources/{id}      (auth+validation reaches us)
  - ?include=renditions exposes the nested VAST tag URIs inline.

Exposed on the client as ``client.creatives`` with list_creatives,
get_creative, and iter_creatives. CRUD writes (POST/PUT/DELETE) are
deferred to a follow-up commit so we can probe shapes against the
live API with a create+cleanup pattern.

Still scope-blocked (publisher must grant):
  - /services/v4/creative_instances — creative <-> placement linkage,
    needed to actually attach a creative to a placement so it delivers.
  - /services/v4/creative_renditions — standalone rendition collection.
  - /services/v4/mkpl_creatives — marketplace creative approval.

Supporting changes:
  - entities.py: Creative + Rendition + CreativeMessage models. Extended
    PaginatedResponse with AliasChoices so both pagination conventions
    work (total_count/total_page for inventory, total/total_pages for
    creative_resources).
  - capture_fixtures.py: added creative_resources to the v4 walk.
  - anonymize_fixtures.py: advertiser_ids / agency_ids (list-of-int)
    plus uri and clearcast_note added to the scrub list. VAST URIs
    replaced with example.invalid placeholders so the third-party
    ad-server hostnames don't leak.
  - tests/helpers/freewheel_replay.py: shared make_response /
    replay_session helpers extracted from the three client test files
    to satisfy the code-duplication guard.

Fixture file churn comes from the deterministic counter-based anonymiser
picking different fake-name values now that creative_resources is in the
input set; semantic content is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): live smoke test + fix XML empty-element coercion

Adds a @pytest.mark.live integration test that exercises the whole
FreeWheel client stack against the real publisher API: token info,
inventory reads, commercial reads, creative reads, and a full
Campaign → IO → Placement create-and-delete cycle. Skipped by default,
runs only when FREEWHEEL_TEST_API_KEY + FREEWHEEL_TEST_ADVERTISER_ID
are set.

Running the test surfaced a real bug. The live API returns campaigns
with ``<agency_id></agency_id>`` when no agency is assigned (empty XML
element), and our ``_element_to_dict`` was emitting ``""`` for those —
which Pydantic ``int | None`` fields couldn't coerce. Fixed by mapping
empty leaf elements to ``None`` instead of ``""`` so optional scalar
fields validate cleanly across the board. The earlier BeforeValidator
on nested model fields (schedule/budget) becomes redundant for the
empty-element case but stays in place as a safety net.

Live test results against Talpa's network (2026-05-12):

  TestAuthAndConnectivity.test_token_info_returns_user_and_expiry      PASS
  TestInventoryReads.test_list_sites_returns_entities                  PASS
  TestInventoryReads.test_list_videos_returns_entities                 PASS
  TestCommercialReads.test_list_advertisers_includes_test_advertiser   PASS
  TestCreativeReads.test_list_creatives_returns_entities               PASS
  TestWriteRoundTrip.test_full_create_and_delete_cycle                 PASS

The write round-trip creates Campaign → IO → Placement, fetches the IO
back, then deletes everything in reverse order. All six assertions land
and all three deletes succeed — Mapping A wires correctly end-to-end
through to the real API.

Registered a ``live`` pytest marker so the suite doesn't need
@pytest.mark.skipif boilerplate on every test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): OAuth2 password-grant auth (api_token kept as escape hatch)

Adds the canonical FreeWheel auth flow — username + password — alongside
the pre-existing pre-minted api_token path. Production users now enter
credentials once; the transport mints a bearer at POST /auth/token on
first use, caches it with TTL tracking, and auto-refreshes on 401 or
expiry. The api_token field is kept as an escape hatch for cases where
a partner has provisioned a token out-of-band (our Talpa setup), or for
testing without managing real credentials.

Exactly one of (username + password) or api_token is required, enforced
at three layers:
  - Pydantic model_validator on FreeWheelConnectionConfig
  - Constructor check in FreeWheelTransport
  - Init check in FreeWheelAdapter (live mode only)

Transport behaviour:
  - api_token mode: bearer used directly, 401 propagates to caller.
  - password-grant mode: mint via POST /auth/token (data: grant_type=password,
    user_id, password). 401 triggers exactly one refresh + retry before
    propagating, in case the cached token rolled prematurely. expires_in
    is honoured with a 1-hour refresh leeway (or expires_in/2, whichever
    is smaller).

UI: connection_config.html now has a "Sign-in Credentials (recommended)"
section with User ID + Password fields, plus an "Advanced: pre-minted
bearer token" <details> block for the escape hatch. The Save flow rejects
submissions that have neither path. The Test Connection flow reports
``auth_mode: password_grant`` vs ``pre_minted_token`` in its response.

Tenant-status reporting accepts either auth path. Config save/update
endpoints accept username, password, and api_token, reject ciphertext
replay on both secret fields, and pass the merged config to
FreeWheelConnectionConfig for validation.

Tests:
  - 15 new unit tests (password-grant mint + cache + 401-refresh + retry +
    error paths, plus full schema validation coverage for both auth paths).
  - Integration roundtrip tests cover both auth modes.
  - Live API test continues to pass via the api_token path (we don't have
    Talpa's username/password to exercise the password-grant path against
    the real API; that's unit-tested only until a real user/password pair
    shows up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): inventory taxonomy sync into local cache

Adds a publisher-internal cache of FreeWheel inventory so the adapter's
product setup UI can pick targeting from FW taxonomy without round-tripping
to the FW API on every page render.

New ``freewheel_inventory`` table (alembic 7c3073bd70cf), keyed by
``(tenant_id, entity_type, entity_id)`` with a denormalised name/parent_id
plus a full JSON-blob ``raw_json`` payload. Stores eight entity kinds:

  - site                 (v4)
  - site_section         (v4)
  - site_group           (v4)
  - series               (v4)
  - video_group          (v4)
  - ad_unit_package      (v4, with nested ad_units folded out)
  - ad_unit              (v4, denormalised from ad_unit_packages — bare
                          /ad_units/{id} is 403-denied on our scope, so we
                          read them through their packages)
  - ad_unit_node         (v3 XML; binds placement → ad_unit, read-only at v3)
  - standard_attribute   (v4 reference data — TV ratings, languages, etc.)

Individual Videos are NOT synced (4,613+ items on Talpa's network; query
on-demand if a product needs to drill into a specific asset).

This table is NOT exposed to AdCP buyers. Buyer-facing property discovery
goes through the AAO lookup path (adagents.json + brand.json, via
src/services/aao_lookup_service.py). The cache exists purely for the
publisher's product configuration UI. See #378 for the cleanup of the
deprecated AuthorizedProperty / PropertyTag tables that this design
intentionally bypasses.

Components:

  - alembic 7c3073bd70cf — create freewheel_inventory table
  - src/core/database/models.py — FreeWheelInventory ORM model
  - src/adapters/freewheel/inventory_sync.py — FreeWheelInventorySync
    service: walks every readable family, upserts via Postgres
    ON CONFLICT DO UPDATE. Per-family errors are captured in SyncResult
    rather than aborting (partial-success policy — some tenants will
    have uneven scope coverage across families).
  - POST /api/tenant/<id>/adapters/freewheel/sync-inventory — admin
    endpoint that reads the stored config, instantiates a client, and
    triggers the sync.
  - templates/adapters/freewheel/connection_config.html — "Sync Inventory
    Now" button + status display showing per-entity-type counts.
  - 7 new unit tests covering SyncResult dataclass, the dispatch
    orchestration with a mock client, partial-failure semantics, and the
    standard_attributes flat-dict code path.

Verified end-to-end against Talpa's live network: 2,542 entities synced
in one call (29 sites, 51 site_sections, 96 site_groups, 324 series,
507 video_groups, 2 ad_unit_packages, 385 ad_unit_nodes, 1148
standard_attributes), then re-runs upsert cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): product setup UI driven by synced inventory cache

The FreeWheel product config schema and template are rebuilt around the
actual FW data model. Instead of asking publishers to type comma-separated
"placement IDs" (the old shape, which never made sense for our adapter
since placements get created per buy), the new product setup UI pulls
choices from the local ``freewheel_inventory`` cache:

  - Sites          (delivery destinations)
  - Site Sections  (optional sub-section scoping)
  - Video Groups   (audience-segmented content — Talpa's primary
                    targeting primitive: "DOELGROEP INDEX 150+", etc.)
  - Series         (specific shows)
  - Ad Unit Package (slot bundle: Pre-Mid, Pre-Mid-Post)
  - TV Ratings     (content rating restrictions, from standard_attributes)

The picker template (templates/adapters/freewheel/product_config.html)
populates each <select> via fetch() against a new admin endpoint
``GET /api/tenant/<id>/adapters/freewheel/inventory?entity_type=X``,
which reads from the cache through a new tenant-scoped repository
(FreeWheelInventoryRepository in src/core/database/repositories/).

Schema changes:
  - FreeWheelProductConfig now exposes the real inventory targeting
    fields (site_ids, site_section_ids, video_group_ids, series_ids,
    ad_unit_package_id, tv_rating_ids) plus pricing controls
    (price_model: ACTUAL_ECPM / FIXED_PRICE, priority).
  - placement_ids is dropped — that field was based on the GAM model
    where placements pre-exist. Doesn't apply to FW's per-buy create.
  - targeting_profile_id and custom_targeting kept under an Advanced
    <details> as escape hatches.

Bug fix in inventory_sync:
  - The /services/v4/ad_unit_packages list endpoint returns metadata
    only — nested ad_units only appear on the single-item GET. The
    sync used to read the list and silently miss the ad_units. Now it
    fans out to each package's detail and dedupes ad_units across
    packages (Pre-roll Ad belongs to both Pre-Mid and Pre-Mid-Post;
    first-write wins on parent_id).
  - _sync_ad_unit_packages now returns (package_count, ad_unit_count);
    the SyncResult tracks both as separate counts.

Verified end-to-end against Talpa's live network: 2,545 entities synced
(was 2,542 before this commit; the new ad_unit count is 3 — Pre-roll,
Mid-roll, Post-roll — deduped from the two packages).

Repository pattern:
  - New FreeWheelInventoryRepository — tenant-scoped reads against the
    freewheel_inventory table. The admin inventory endpoint goes
    through this rather than raw select() (structural guard
    test_architecture_no_raw_select.py was enforcing this).

Adapter dry-run logs now mirror the new product config shape (site_ids,
video_group_ids, etc.) so what the operator sees in dry-run matches
what they configured.

What this does NOT do yet (still blocked on v4 scope grants):
  - Drive actual delivery targeting. The product config carries the
    intent (which sites/video_groups/etc. to target), but binding that
    to FW ad_unit_nodes at buy time requires v4 ad_unit_nodes write
    scope. See adapter.py docstring for the full block list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): full targeting surface + canonical VAST video formats

Two related expansions of the FreeWheel adapter, both based on the
audit of standard_attributes we synced from Talpa's network.

## FreeWheelProductConfig — every supported targeting dimension

The standard_attributes sync from Phase G pulled in 1,148 targeting
primitives across 15 categories. Most are *structured equivalents* of
what GAM publishers express through custom key-values — FW models them
as typed first-class fields rather than free-form k/v pairs. The product
config now exposes every one we have synced data for:

  Inventory          site_ids, site_section_ids, video_group_ids,
                     series_ids, ad_unit_package_id
  Audience           viewership_profile_ids (29 standardized profiles),
                     audience_item_ids (Data Suite — gated, stored only)
  Content            genre_ids (426), content_daypart_ids (3),
                     content_duration_ids (3), content_territory_ids (251),
                     language_ids (42), tv_rating_ids (266)
  Delivery context   device_type_ids (76), os_ids (3), environment_ids (2),
                     stream_type_ids (7), subscription_model_ids (3)
  Privacy            addressability_ids (32), privacy_signal_ids (3)
  Pricing            price_model (ACTUAL_ECPM | FIXED_PRICE), priority

This gives FreeWheel publishers the full expressive range of their
network through the product setup UI. Each new dimension is backed by a
<select> picker that loads from the freewheel_inventory cache via the
existing GET /api/tenant/<id>/adapters/freewheel/inventory endpoint.

The adapter's dry-run _line_item_payload echoes every list dimension so
operators can verify intent in dry-run logs before flipping to live mode.

Note: custom_targeting (the FW v4 custom_keys API) is still gated by
scope on our token — kept as the escape hatch under an Advanced
<details>, but most use cases that would have needed it on GAM are
covered by the structured fields above.

## FreeWheelAdapter.get_creative_formats() — six canonical VAST formats

New src/adapters/freewheel/formats.py declares six static AdCP-shaped
formats covering pre/mid/post-roll × 15s/30s:

  freewheel_video_15s_pre_roll       freewheel_video_30s_pre_roll
  freewheel_video_15s_mid_roll       freewheel_video_30s_mid_roll
  freewheel_video_15s_post_roll      freewheel_video_30s_post_roll

Each format declares a single VAST tag URL asset and {vast: true}
delivery hint. Validated against adcp.types.Format on every test run.

Declared statically (Option A) rather than synthesised from synced data
because (a) AdCP's format registry is mostly static, (b) the six
combinations cover the common buyer case for video VAST forwarding, and
(c) static format IDs stay stable across inventory-sync runs so buyer
references don't break when Talpa edits their ad_unit_packages.

Tests:
  - 6 unit tests for the static format list and Format schema validation
  - Schema test for the new product config fields, full round-trip via
    model_dump → model_validate

4328 unit tests pass. Live FW integration test still green via api_token
escape hatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): surface synced inventory via get_available_inventory + README refresh

Two free wins that need no FW scope grant:

- Override AdServerAdapter.get_available_inventory() to serve the AI product
  configurator from the freewheel_inventory cache. Returns placements
  (ad_unit_packages), ad_units (sites + site_sections), targeting_options
  (standard_attributes grouped by taxonomy key), the static VAST creative
  specs, and cache properties. Live-verified against Talpa: 2 packages,
  80 ad units, 15 targeting groups, 6 formats, 1148 attributes.

- Rewrite docs/adapters/freewheel/README.md to match what's actually shipped:
  password-grant auth (with api_token escape hatch), full inventory sync
  taxonomy, 18-dimension product config, live coverage matrix, and the
  layered scope-grant ask (Tier 1: lifecycle; Tier 2: reporting; Tier 3:
  operator UX; Tier 4: future). Previous README still described the
  client_credentials path we abandoned and claimed skeleton-only status.

Test: tests/unit/test_freewheel_adapter.py::TestGetAvailableInventory
covers shape and grouping semantics with mocked repository.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): reporting cache scaffold — read paths + stub sync (scope-pending)

Build everything that's anchored on AdCP's contract (which is fixed) so
day-of-scope the only new work is the actual FW Reporting API client.

Adds:
- Migration + ORM: freewheel_placement_stats cache table (per-placement
  impressions/spend_micros/completed_views/clicks/currency/delivery_status,
  keyed by (tenant_id, placement_id), with IO-scoped index for delivery
  aggregation). Spend stored in micros to dodge floating-point drift.
- Repository (FreeWheelPlacementStatsRepository): tenant-scoped reads via
  get_by_placement_ids() and list_by_insertion_order(), plus a Postgres
  ON CONFLICT bulk_upsert() for the sync job to call.
- get_packages_snapshot(): reads from cache, returns Snapshot per package.
  Missing rows surface as None so callers render a "no data" state rather
  than fail. Staleness derived from row.as_of. Delivery status mapped to
  the AdCP DeliveryStatus enum where the FW value maps cleanly.
- get_media_buy_delivery(): aggregates per-placement rows into
  DeliveryTotals + by_package list. Empty cache falls through to the base
  helper's zero-response shape.
- FreeWheelReportingSync stub: raises ReportingScopeNotGranted with a
  pointer to the README scope ask. Schedulers can catch this and degrade
  gracefully — read paths already tolerate the empty-cache state.

Tests pin the read-side contract (tests/unit/test_freewheel_reporting_cache.py,
7 cases). When FW grants Tier 2 scope, the only new work is implementing
the four private methods on FreeWheelReportingSync (submit_job, poll_job,
fetch_results, parse_rows) against the real Query Reporting endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tenant-mgmt-api): typed adapter configs + GET /adapters discovery

Two gaps that blocked Scope3 from using anything beyond GAM + Mock through
the typed tenant-management API:

1. The AdapterConfig discriminated union in src/admin/api_schemas/
   tenant_management.py only listed GAM + Mock. Typed embedder clients
   couldn't POST type="freewheel" / "triton" / "broadstreet" — spectree
   validation rejected anything else, even though the legacy
   /api/tenant/<id>/adapter-config endpoint (operator-facing) handled
   them. Adds:
     - FreeWheelAdapterConfig (with the username+password OR api_token
       cross-field rule)
     - TritonAdapterConfig (auth_type + creds + base/login URLs)
     - BroadstreetAdapterConfig (network_id + api_key)
   Secrets use SecretStr. Persistence round-trips through each adapter's
   own connection schema so Fernet encryption lands consistently in
   AdapterConfig.config_json — same path the legacy endpoint uses.

2. No way to discover what adapters this Sales Agent instance supports.
   Adds GET /api/v1/tenant-management/adapters returning the full catalog
   per adapter type: name, description, default_channels, capabilities
   (mirrors AdapterCapabilities), and the connection_schema JSON Schema
   so embedders can validate locally before POSTing. Sourced from
   ADAPTER_REGISTRY so new adapters auto-appear once they're registered
   and have a typed AdapterConfig member.

Test plan:
- tests/unit/test_tenant_management_schemas.py: 13 new schema-level
  tests covering each typed config's happy path + rejection paths +
  discriminator routing through ProvisionTenantRequest.
- tests/integration/test_tenant_management_api_integration.py: 2 new
  endpoint tests (catalog shape + auth gate).
- Regenerated docs/api/tenant-management-openapi.{json,yaml}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(adapters): adapter playbook — phase-by-phase checklist for new adapters

11-phase walkthrough of everything needed to add a new ad-server adapter
end-to-end: pre-work API probing, adapter package scaffolding, inventory
+ reporting caches, three-place registration (registry / typed API config /
discovery catalog), admin UI, admin endpoints, test coverage, docs,
OpenAPI regeneration, smoke + quality gates, common gotchas, ship.

FreeWheel is called out as the reference implementation with specific
file pointers per step. Captures the lessons from PR #381 — stale
uvicorn imports, migration head collisions, DeliveryStatus enum
mismatch, BuildKit stale-deps surprise, the DRY guard ratchet, etc.

Also fixes the stale FreeWheel description in docs/adapters/README.md
(client_credentials → password grant) and surfaces the new playbook
from the index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tenant-mgmt-api): add tier flag to adapter discovery (mock=test, rest=live)

Emma flagged that the discovery endpoint exposes Mock to embedders — which
is real (it's a registered adapter we use in tests and demos) but should
never appear in a production storefront's picker.

Adds:
- ``tier`` field on AdapterCatalogEntry: ``"live"`` (production adapter)
  or ``"test"`` (simulated/dev-only). Mock is the only ``"test"`` adapter
  today; everything else is ``"live"``.
- ``?tier=live`` and ``?tier=test`` query filter on GET /adapters so
  production storefronts can opt out of seeing the test surface server-side
  (rather than having every embedder filter client-side). Unknown values
  return 400.

Default behaviour returns all adapters with their tier tag so dev consoles
keep seeing the full set. Production storefronts pass ?tier=live.

Test plan:
- 3 new endpoint tests in test_tenant_management_api_integration.py
  (live filter excludes Mock, test filter returns only Mock, invalid
  value rejected with 400) — all green.
- Existing catalog assertion updated to check the new tier field.
- Regenerated docs/api/tenant-management-openapi.{json,yaml}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(adapters): park Triton — APIs not production-ready, surface removed

Triton told us their TAP Media Buying API isn't production-ready (2026-05).
Surface-removal approach (vs. full deletion) so we can restore via revert
when their APIs come back.

Removed from every customer-facing surface:
- ADAPTER_REGISTRY: triton + triton_digital entries dropped, so tenants
  cannot select 'triton' as ad_server (legacy POST /tenants now rejects
  it via an ADAPTER_REGISTRY membership check; spectree POST
  /tenants/provision rejects via the discriminated AdapterConfig union).
- AdapterConfig discriminated union: TritonAdapterConfig removed.
- Discovery catalog (_ADAPTER_CATALOG_METADATA + _ADAPTER_CONFIG_TYPED):
  triton excluded from GET /api/v1/tenant-management/adapters.
- tenant_settings.html: picker card hidden (with a comment pointing at
  the parked module path for restoration).
- adapters.py blueprint: test_triton_connection endpoint removed.
- docs/adapters/README.md: Triton section + table row replaced with a
  short parked-state notice.
- docs/adapters/triton/README.md → README.parked.md with a header
  explaining the parked state.

Kept parked (so restoration is a revert, not a rebuild):
- src/adapters/triton/* — the adapter module + client + targeting
- tests/unit/test_triton_*.py — direct-construction tests still run;
  TestRegistry tests flipped to assert parked-state behaviour.
- templates/adapters/triton/* — connection + product templates unreachable
  but preserved.
- Alembic migrations — unchanged.

Existing tenants whose adapter_type is already 'triton' (if any) remain
operable: the update path in tenant_management_api.py preserves their
config_json handling.

Tests:
- test_tenant_management_schemas.py: removed TritonAdapterConfig happy-
  path tests; added test_provision_request_rejects_parked_triton_adapter
  to pin the embedder-side rejection.
- test_tenant_management_api_integration.py: catalog assertions exclude
  triton from both the all-adapters and tier=live responses.
- test_new_product_filters.py + test_triton_adapter.py registry tests
  flipped to assert the parked-state behaviour.
- 4,367 passed / 14 skipped / 19 xfailed — all green.
- Regenerated docs/api/tenant-management-openapi.{json,yaml}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(adapters): permission probes — catch IAM gaps at connect, not mid-campaign

Brian flagged that operators today discover missing upstream permissions
when a campaign fails halfway through, instead of seeing them at connect
time. This adds a structured permission-check primitive on AdServerAdapter
and a live FreeWheel implementation.

The pattern:
1. AdServerAdapter.check_permissions() returns a PermissionsReport with
   per-endpoint PermissionCheck entries: name, description, granted,
   required vs nice-to-have, feature label (creative_trafficking,
   delivery_reporting, etc.), and a detail string for failed probes.
2. fully_operational rolls up to True only when every required probe
   passes. Optional probes can deny without blocking the rollup —
   surfaces partial-scope state correctly.
3. Each adapter implements its own probe — auth flows differ enough
   that a generic HTTP prober doesn't fit. Base class returns an
   empty report so adapters that haven't implemented yet behave as
   "no checks declared, fully_operational=True".

FreeWheel implementation probes 14 endpoints covering auth, inventory
sync, commercial CRUD, creative trafficking, reporting, audiences,
targeting profiles, and webhooks — every AdCP feature path. Live probe
against Talpa correctly reports our current state: 9 required probes
granted, 1 required denied (/services/v4/ads — the creative trafficking
blocker), 4 optional denied (reporting + audiences + targeting profiles
+ webhooks).

Probe semantics that took some thought:
- 4xx validation (400/404/422) counts as GRANTED — endpoint accepts the
  call, just needs different params. Our minimal probes intentionally
  send empty payloads so we don't accidentally mutate state.
- 401/403 count as denied (real scope gap).
- Auth-token failures bail the whole pass with report.error set; we
  don't paint every endpoint as "denied" when the real problem is a
  bad token, that'd mislead operators.

New admin endpoint:
  POST /api/tenant/<tenant_id>/adapters/<adapter_type>/check-permissions

Loads the configured adapter, runs check_permissions(), returns the
JSON report. Read-only (every probe is a GET) so opts into the
embedded-write gate. Available to admin or member roles.

Test plan:
- tests/unit/test_freewheel_permissions.py: 11 cases covering dry-run
  short-circuit, granted/denied semantics, the validation-error
  edge case, 401 mapping, auth failure handling, probe target
  cleanup, and the every-check-has-a-feature invariant. All pass.
- Live-verified against Talpa: correctly identifies /services/v4/ads
  as the one required denial blocking creative trafficking.
- make quality: 4,378 passed / 14 skipped / 19 xfailed.

Follow-up not in this PR: UI rendering of the checklist on the adapter
settings page; surfacing fully_operational on the discovery catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): permission-check UI + correct creative_trafficking model

Brian flagged that FW's docs don't actually have an "Ad" concept and
asked us to verify. Re-reading the creative_instances POST docs revealed
the parameter ad_id is described as "The Ad Unit Node ID to link
Creative" — there is no separate Ad object. The /services/v4/ads scope
we were chasing was misdirected.

Verified live: POST /services/v4/creative_instances with
ad_id=<ad_unit_node_id from inventory sync> and creative_id=<creative_resource_id>
returns 201 Created with FW auto-deriving placement_id on the response.
The entire creative trafficking flow is unblocked today.

UI:
- Added "Check API Permissions" button to the FreeWheel adapter settings
  page. Hits POST /api/tenant/<id>/adapters/freewheel/check-permissions,
  renders a per-feature checklist showing granted/denied with the
  probe target endpoint and the AWS API Gateway deny detail when the
  scope is missing. Operators see at-connect-time which AdCP features
  will work, instead of discovering missing scopes mid-campaign.

Code:
- check_permissions probe list: dropped v4_ads (wasn't needed); kept
  v4_creative_instances as the required probe with a comment explaining
  the ad_id ↔ ad_unit_node_id alias.
- Unit tests updated to use creative_instances as the canonical required
  probe (11 cases still passing).
- Live probe against Talpa now reports fully_operational=true. Only
  Tier-3/4 nice-to-haves remain denied (reporting, audiences,
  targeting profiles, webhooks).

Docs:
- README "Scope grants still needed" rewritten: Tier 1 ads grant is
  removed; Tier 1 is now Query Reporting (path TBD). Added a "What we
  no longer need to ask for" section explaining the ad_id alias and
  the v4-doesn't-exist-for-commercial finding so the next person looking
  at this doesn't go down the same dead ends.
- Coverage matrix: add_creative_assets flipped from 🟡 partial (blocked)
  to ✅ unblocked; associate_creatives from ⏳ blocked to 🟡 wired-ready
  (FW writes work — adapter just needs the ad_unit_node lookup chain
  from cache, which is a follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): pinpoint Reporting API location, update scope ask + probes

Probed FW's full host surface to find where their Reporting API actually
lives — turns out it's at api.freewheel.tv/reporting/* (singular, host
root, NOT under /services/v*). Every /reporting/* path returns the AWS
API Gateway IAM-deny payload for our test user, confirming the resources
exist and only a scope grant is needed.

Verified surface (all currently denied):
  POST /reporting/jobs                         — submit async job
  GET  /reporting/jobs/{id}                    — poll status
  GET  /reporting/jobs/{id}/result(s)/download — fetch CSV/JSON
  GET  /reporting/queries + /saved_queries     — saved-query CRUD
  GET  /reporting/dimensions + /metrics        — schema introspection

Adjacent host-root paths (/reports, /reporting at the top, /insights,
/analytics, /graphql, etc.) all returned nginx-level HTML denies rather
than AWS IAM-deny, confirming /reporting/* is the actual surface and
others are dead-end aliases.

Code:
- check_permissions(): swapped the wrong /services/v4/reports probe for
  two correct /reporting/* probes (schema introspection + job submit).
- reporting_sync.py: dropped TBD docstring; now documents the full
  /reporting/* surface map so day-of-scope is just filling in the
  four private methods.
- Unit test parameter updated to match the new probe path.

Docs:
- README "Scope grants still needed" now lists the specific endpoints
  to ask for. Updates the Mathijs ask from "we don't know where
  reporting lives" to "grant our user IAM access to /reporting/* —
  specific paths listed".

Live probe (against Talpa, user 35696) cleanly shows fully_operational=true
plus two new entries under feature=delivery_reporting both denied, ready
to flip the moment scope arrives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): re-point FW merge revision to chain through r0s1t2u3v4w5

origin/main added migration r0s1t2u3v4w5_add_proposals_table.py which
descends from 8820c87e8ae3 — the same parent our merge revision
190d6e98754b already chained from. That made them siblings and produced
two migration heads, which the test_architecture_single_migration_head
guard catches at quality-gates time. CI was tripping on the same check
because migrate.py refused to apply with "Multiple head revisions are
present", which cascaded into every DB-touching integration + E2E test.

Re-point 190d6e98754b's main-side parent from 8820c87e8ae3 to
r0s1t2u3v4w5 (which itself descends through 8820c87e8ae3 →
17423a1b551e → base). Graph converges to a single head; alembic
history is linear-ish again.

Verified locally:
  $ uv run alembic heads
  190d6e98754b (head)

  $ make quality
  4,438 passed / 14 skipped / 19 xfailed

The commit message comment in the migration is updated to note that
the main-side parent will move forward as new migrations land on main
— each subsequent origin/main merge re-points this parent again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): implement Reporting client + wire sync end-to-end

Builds the Query Reporting API client speculative-but-defensive against
the /reporting/* surface we mapped via probing. Day-of-scope this fires
real reports against FW; day-zero it raises ReportingScopeNotGranted
cleanly on the 403.

Code:
- src/adapters/freewheel/_reporting.py: FreeWheelReportingClient with
  submit_job / get_job / wait_for_completion / fetch_results. JobSpec
  (Pydantic) serialises POST /reporting/jobs bodies. JobStatus parses
  enum-strings case-insensitively and clamps unknown values to UNKNOWN
  so a new FW status doesn't break the polling loop. JobState parses
  both snake_case and camelCase payloads defensively, preserves the
  raw dict for fields we don't yet know about. ColumnMap is a single
  tunable for FW's result column names — day-of-scope edit one place
  when we see the real column labels.
- src/adapters/freewheel/reporting_sync.py: FreeWheelReportingSync.run()
  now actually orchestrates submit/poll/fetch/upsert. ForbiddenError
  caught once at the top and re-raised as ReportingScopeNotGranted so
  callers get a clean signal. Cache upsert via the existing
  FreeWheelPlacementStatsRepository.bulk_upsert.
- src/adapters/freewheel/_transport.py: added post_json + delete_json
  helpers (existing v3 surface only had post_xml + delete_xml).
- src/admin/blueprints/adapters.py: new POST endpoint
  /api/tenant/<id>/adapters/freewheel/sync-reporting — admin-only,
  same shape as sync-inventory. Returns 503 with scope_pending=true
  when the upstream IAM-denies us so the UI can render the right copy.
- templates/adapters/freewheel/connection_config.html: added "Sync
  Reporting Now" button + syncFreeWheelReporting() JS. UI lives
  between Inventory Sync and API Permissions so the operator's first
  three buttons match the natural flow: connect → inventory → reporting.

Tests (tests/unit/test_freewheel_reporting_client.py — 27 new):
- JobSpec serialisation (minimum + with filters)
- JobStatus parsing (5 known values + unknown clamps to UNKNOWN + None)
- JobState parsing (snake_case + camelCase + preserves raw + error_message)
- parse_row (default map, string-numbers coercion, missing fields,
  garbage input, custom ColumnMap remap, as_of fallback to now)
- submit_job round-trips the request body
- wait_for_completion (immediate-terminal, polls PENDING→RUNNING→COMPLETED,
  timeout raises with last state, CANCELED is terminal)
- fetch_results (inline rows, alternate keys 'rows'/'results'/'data',
  raises when job not complete)

Cache test updated (tests/unit/test_freewheel_reporting_cache.py): the
scope-handling tests now patch transport.post_json to raise
FreeWheelForbiddenError, matching production behaviour rather than the
old "raises unconditionally" stub.

Live verified (against Talpa, user 35696): sync.run() correctly
attempts POST /reporting/jobs, FW returns 403, our code catches it once
and raises ReportingScopeNotGranted with the friendly message. Day-of-
scope the same code path will fire and run the actual report — if FW's
request shape differs from our spec, ColumnMap + JobSpec serialisation
have explicit edit points.

Docs: README live-coverage matrix updated:
  get_media_buy_delivery / get_packages_snapshot: ⏳ stub → 🟡 wired
  (reads cache; populated by sync once scope lands)

make quality: 4,465 passed / 14 skipped / 19 xfailed (gained 27 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(freewheel): stop false-zero delivery webhooks when reporting cache is empty

Brian flagged that the DeliveryWebhookScheduler runs hourly automatically,
calls adapter.get_media_buy_delivery() per active media buy, and fires
webhooks to buyers — but our FW adapter was returning zero-delivery
responses when the placement_stats cache was empty (which is the steady
state today, since the Reporting API scope is still pending). Buyers
polling AdCP or subscribing to delivery webhooks would see fake
"delivering=0 impressions" signals every hour, which is misleading.

Fix: introduce a soft-error signal that distinguishes "integration
healthy, no data YET" from "integration broken":

- New AdServerAdapter base exception DeliveryDataUnavailable. Adapters
  raise it when they have no data to report but nothing is actually
  wrong upstream — typical causes: cache not yet populated, upstream
  reporting scope still pending. Shareable across adapters.

- FreeWheelAdapter.get_media_buy_delivery now raises
  DeliveryDataUnavailable when the placement_stats cache has no rows
  for the requested insertion order, instead of returning zeros via
  the base _empty_delivery_response helper.

- _get_media_buy_delivery_impl catches DeliveryDataUnavailable
  separately from the generic adapter-error catch-all. The clean error
  surfaces as a GetMediaBuyDeliveryResponse with errors=[Error(
  code="data_unavailable")] — no audit log, no warning-level noise,
  just an info-level "data not yet available" log.

- DeliveryWebhookScheduler's soft-skip set widened from just
  {"media_buy_status_excluded"} to also include "data_unavailable" —
  same info-level skip, no false-zero webhook fires.

Test (tests/unit/test_freewheel_reporting_cache.py): the empty-cache
test flipped from "returns zero response" to "raises
DeliveryDataUnavailable with media_buy_id set". This pins the contract
that AdCP layer + scheduler depend on.

Defer-list captured in expanded comment on #382: the proper fix for
this whole area (per-adapter buttons → shared scheduler + uniform
adapter contract + /admin/scheduling page) is significant scope
and should be its own PR after #381 merges. This change is the
minimum-surgical fix to stop bad signals today.

make quality: 4,465 passed / 14 skipped / 19 xfailed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel ui): proactive scope-pending banner on adapter settings page

Operators were finding out about missing FW scope two ways: by clicking
"Check API Permissions" (which they wouldn't do unprompted), or by a
buyer reporting "I'm seeing data_unavailable for delivery." Both are
surprises. Surface the state on page load instead.

Adds a banner above the FW configuration form that auto-runs
check_permissions and renders one of three states:

- (no banner): fully_operational, nothing surprising.
- (warn, amber): reporting scope is denied. Banner explains buyers will
  see data_unavailable until granted; other features work normally.
  Reporting is technically a "nice-to-have" probe in our report shape,
  but its absence has real operator-visible consequences worth flagging.
- (error, red): a required probe failed. Banner lists the missing
  features.

Other denied nice-to-haves alone (targeting_profiles, audiences,
webhooks) don't trigger the banner — they're true optionals and would
become noise. They remain visible in "Check API Permissions" for
operators who care.

Banner has a "See full permissions checklist →" link that scrolls down
and triggers the existing on-demand probe so the operator sees the
full per-endpoint breakdown.

Quietly no-ops on auth-level failure / no creds (those are surfaced by
the credentials section already) and on transient probe failures (page
should still load).

make quality: 4,465 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(freewheel): wire creative trafficking + stale-cache freshness banner

Two gaps closed in one commit so the FW adapter is feature-complete
for the buyer-facing flow (create buy → traffic creatives → see
delivery) before #381 merges.

### Creative trafficking — end-to-end

Earlier we proved /services/v4/creative_instances POST works (via
Brian's docs-read showing ad_id = ad_unit_node_id). Now actually wired:

- src/adapters/freewheel/_creatives.py: added create_creative,
  delete_creative, create_creative_instance, delete_creative_instance.
  Two wire-shape gotchas captured (verified live against Talpa):
    * POST creative_resources: body must be wrapped under
      {"creative": {...}}; flat body returns 400 "Creative Node is
      missing". Response is doubly-wrapped: {"data": {"creative": {...}}}.
    * POST creative_instances: ``ad_id`` is FW's param name but its
      docs say "The Ad Unit Node ID to link Creative." Response auto-
      populates placement_id (FW derives it from the ad_unit_node).
- src/adapters/freewheel/adapter.py:
    * add_creative_assets: POSTs one creative_resource per AdCP asset,
      stamps the AdCP id onto FW external_id for lineage, returns
      AssetStatus(creative_id=<FW id as string>, status="approved").
    * associate_creatives: looks up ad_unit_node_ids per placement
      from the inventory cache, POSTs one creative_instance per
      (node, creative) pair. Per-binding result rows so callers see
      partial successes. Skipped placements (no cached ad_unit_nodes
      → run inventory sync first) get a clear message rather than
      silent failures.

Live cycle verified end-to-end against Talpa: create_creative →
create_creative_instance → delete_creative_instance → delete_creative,
all clean.

### Stale-cache freshness banner

Two new repository methods (latest_sync_at) on the inventory + placement-
stats repos. New GET /api/tenant/<id>/adapters/freewheel/cache-freshness
endpoint returns last_synced_at + age_seconds + stale flag + threshold
for both caches. Threshold defaults: 24h inventory, 2h reporting.

UI: second banner above the FW config form (alongside the scope-pending
banner). Renders only when something needs flagging:
  - blue (info): cache never synced — onboarding gap
  - amber (warn): cache stale — sync probably broken
  - no banner: everything fresh

### Test infra cleanup

Extracted tests/helpers/freewheel_adapter_patches.py::patch_freewheel_db
so the same FreeWheelInventoryRepository + get_db_session monkeypatch
block isn't duplicated across test modules. Both
test_freewheel_adapter.py and test_freewheel_creative_trafficking.py
now use the helper. Duplication guard happy again.

### Tests

- tests/unit/test_freewheel_creatives.py: +4 cases pinning the write
  surface (wrapped POST body, ad_id semantics, DELETE paths).
- tests/unit/test_freewheel_creative_trafficking.py: 9 cases — dry-run +
  live + fan-out + partial-failure + missing-inventory-skip.
- All existing tests still pass via the shared helper.

make quality: 4,477 passed (gained 12).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prebid#409)

* fix(audit): stop double-encoding audit_logs.details + repair migration

log_security_violation passed details=json.dumps({...}) into the JSONB
column. JSONType.process_bind_param then serialized that already-stringified
JSON again, so the row landed with a JSONB value of type 'string' instead
of 'object'. Strict readers (notably tenant_export.py) refuse those rows,
which blocked tenant exports on every tenant that had ever had a security
violation logged — ~1,272 rows across multiple production tenants.

Fix:
- src/core/audit_logger.py:282 — pass the dict directly; JSONType handles
  serialization. One-line change.
- alembic migration s1t2u3v4w5x6 — repair existing rows with
  UPDATE audit_logs
  SET details = ((details::jsonb) #>> '{}')::jsonb
  WHERE details IS NOT NULL AND jsonb_typeof(details::jsonb) = 'string';
  Idempotent: re-running matches zero rows on a clean DB. Downgrade is
  intentionally unsupported (re-encoding would re-introduce the bug); raises
  NotImplementedError with an explanation rather than silently corrupting.
- tests/integration/test_audit_logger_details_shape.py — regression test
  asserting log_security_violation persists details as a JSONB object,
  not a JSON string. Checks both the ORM read (dict) and Postgres
  jsonb_typeof = 'object'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migration): make s1t2u3v4w5x6 downgrade a true no-op

The previous downgrade raised NotImplementedError to refuse re-corrupting
repaired rows, but that broke test_managed_tenant_migrations_roundtrip
which drives the chain backward to verify reversibility.

Replace the raise with a SQL NOTICE. The body stays non-empty (migration-
completeness guard happy), the data fix stays in place on downgrade
(repaired rows are schema-compatible with all prior revisions), and the
roundtrip test can step through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley and others added 28 commits May 29, 2026 21:53
* fix: project wholesale products from inventory bundles

* fix: resolve inventory analytics migration head

* fix: separate wholesale bundles from brief products

* fix: align product tests with wholesale bundle discovery

* fix: align sandbox product tests with brief discovery

* fix: align mcp wholesale roundtrip fixture
* fix: canonicalize wholesale creative format refs

* fix: accept canonical reference creative agent alias
…#684)

* fix: clarify wholesale forecast and pricing metadata contract

* fix: remove wholesale system metadata inputs
…d#687)

* feat: auto-provision default GAM advertiser on tenant provision

When provisioning a GAM-backed managed tenant without an explicit
default_gam_advertiser_id, the provision endpoint now automatically
calls ensure to create an "Interchange - Default" advertiser in GAM
and records its ID on the tenant.

Without a default advertiser, the buyer-advertiser routing chain raises
TENANT_NOT_ACTIVATED for any media buy that lacks an explicit mapping,
making the tenant unable to receive orders. This makes GAM tenants
immediately operational without a separate setup step.

The auto-provision is best-effort: failures are logged but do not
fail the provision response. Callers can still set
default_gam_advertiser_id explicitly on the request to opt out of the
auto-create (e.g. when they already know the desired advertiser ID).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add push_notification_config to GetProductsRequest per AdCP spec

The live AdCP spec added push_notification_config to get-products-request.json
but the installed adcp library has not caught up. Adding it to our subclass so
the schema alignment test passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: suppress unreleased-fix CVEs and check cache before GAM advertiser provision

Security audit: aiohttp GHSA-jg22-mg44-37j8/GHSA-hg6j-4rv6-33pg, authlib
PYSEC-2026-188, and PyJWT PYSEC-2026-175/177/178/179 have no released fix
versions yet. Added to the ignore-vulns list with comments; remove each entry
when the upstream fix ships.

GAM provisioning: _auto_provision_gam_default_advertiser now checks the local
sync cache for an existing advertiser with the default name rather than calling
gam_ensure_advertiser_companyservice unconditionally. If nothing is cached,
it skips and lets the operator set the default via the ensure endpoint. This
avoids creating GAM advertisers without explicit operator intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add provision_default_resources flag for default advertiser setup

Adds an explicit opt-in flag to ProvisionTenantRequest. When
provision_default_resources=True, the provision endpoint ensures adapter-specific
default resources exist: for GAM, it checks the local advertiser cache first and
calls gam_ensure_advertiser_companyservice if nothing is cached. Defaults to
False so callers must opt in.

Reverts push_notification_config from GetProductsRequest subclass (waiting for
adcp SDK to expose it on the library type) and tracks it in
KNOWN_SCHEMA_LIBRARY_MISMATCHES instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ions (prebid#688)

* fix: defer SpringServe demand_partner_id check to buyer-facing operations

The sync orchestrator constructs adapters using a stub principal without
a demand_partner_id, causing every SpringServe inventory/reporting sync
to fail with a ValueError at adapter construction time — before any sync
work runs. The demand_partner_id is only needed for campaign, demand-tag,
and creative creation, not for read-only sync paths.

Moves the guard out of __init__ into _require_demand_partner_id(), called
at the entry point of each buyer-facing mutating method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: don't validate demand_partner_id inside dry-run code path

_demand_tag_kwargs was calling _require_demand_partner_id(), but it is
invoked from the dry-run block in create_media_buy where no
demand_partner_id is needed — the kwargs are only logged, not sent to the
API. In live mode, _require_demand_partner_id() is already called at the
top of the live path before the loop that calls _demand_tag_kwargs, so
the live-mode validation was redundant there too.

Use self.demand_partner_id or 0 in _demand_tag_kwargs (0 is a harmless
placeholder in dry-run; a valid ID in live mode after the caller has
already validated). Adds a test that dry-run create_media_buy succeeds
with no demand_partner_id configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: ruff format test file

* fix: add ag-ui-protocol and grpcio to security scan quarantine list, acknowledge push_notification_config spec lag

Security audit: uv-secure crashes with an empty unhandled exception on
ag-ui-protocol and grpcio when fetching their PyPI metadata. Added both
to the lockfile-strip quarantine list following the same pattern as the
existing mistralai/types-psycopg2/charset-normalizer entries.

Schema alignment: AdCP spec now includes push_notification_config on
GetProductsRequest but the installed adcp Python library does not yet
expose it. Added to KNOWN_SCHEMA_LIBRARY_MISMATCHES following the same
pattern as the existing get-media-buy-delivery-request entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Harden inventory sync stale-row recovery and prevent destructive full-sync cache clears before successful discovery.
fix: chunk truncated GAM pricing reports
fix: tolerate single-placement GAM pricing caps
fix: Handle inapplicable GAM derived sync status
…rebid#703)

* feat: FreeWheel API-Access client_credentials auth + sandbox support

Adds a third FreeWheel auth mode (OAuth2 client_credentials) so the adapter
can use API-Access app credentials, plus a sandbox environment/host. Proven
live against api.sandbox.freewheel.tv.

- _transport.py: client_credentials grant minted from a decoupled token_url
  (API-Access token service is a different host than the data plane) via HTTP
  Basic, wired into BearerTokenCache.mint_fn so it auto-refreshes; 5-min leeway
  for ~1h tokens. Both minters share _mint_via_oauth (DRY). Adds SANDBOX_BASE_URL.
- schemas.py: client_id / client_secret (Fernet-encrypted) / token_url
  (https-enforced) fields; sandbox env + host; updated credential validation.
- client.py / adapter.py: thread the new params through; adapter reads them
  from config and selects the host by environment.
- create_media_buy: set external_id on the IO (po_number) and each placement
  (package_id) for AdCP->FreeWheel lineage — validated against the sandbox.
- admin: connection_config.html gets client_id/secret/token_url inputs + a
  Sandbox option; the test-connection route + adapter_connection_tester probe
  via an inventory read for client_credentials (token_info 401s for API-Access
  tokens); auth-mode helpers handle the third mode.

Tests: client_credentials mint, sandbox host, secret encryption, https
enforcement, external_id lineage, auth-mode clearing, and the tester's
client_credentials branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): bump aiohttp, authlib, pyjwt for security advisories

Clears the uv-secure pre-push gate (7 CVEs):
- aiohttp 3.13.5 -> 3.14.1  (GHSA-jg22-mg44-37j8, GHSA-hg6j-4rv6-33pg)
- authlib 1.6.11 -> 1.6.12  (PYSEC-2026-188); capped <1.7 to stay on the
  1.6.x patch line and avoid the joserfc-backed 1.7 refactor
- pyjwt 2.12.1 -> 2.13.0    (PYSEC-2026-175/177/178/179)

Per-library breaking-change audit: aiohttp + authlib changes are server/grant
-side and don't touch our client usage (risk none). pyjwt 2.13 now rejects
empty HMAC keys; production is unaffected (decode uses verify_signature=False),
but the id-token-fallback test built a fixture token with an empty key — fixed
to use a throwaway non-empty key (value is irrelevant; decoded unverified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): bump adcp 6.3.0b7 -> 6.3.0b8

Fixes the pre-existing GetProductsRequest schema-alignment failure (prebid#690):
b8's GetProductsRequest includes push_notification_config, matching the
live AdCP JSON schema.

b8 also evolves the generated types; reconciled three tests with no
behavior/wire change on our side:
- StrEnum-based enums: str(enum) now yields the value (not the
  "CreativeSortField.created_date" repr), and a StrEnum member is also a str
  instance. Updated test_query_summary_sort_applied_serializes_enum_values and
  test_model_dump_mode_override to assert enum identity / value rather than the
  old impl details. JSON wire output (model_dump mode="json") is unchanged.
- New Product fields sponsored_placement_types + social_placement_surfaces
  (adcp 6.3) are inherited from the library Product and not persisted — added
  to the schema-database-mapping computed_fields allowlist, matching the
  existing 3.9/3.10/3.12/4.4 entries.

b9 was rejected: no dependency solution on the Python 3.14 resolution split
(our requires-python is >=3.13). b8 resolves cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sadrultoaha sadrultoaha merged commit 88a2bf9 into develop Jun 11, 2026
2 of 6 checks passed
@sadrultoaha sadrultoaha deleted the feature/GAM-Graceful-OrderApproval branch June 11, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants